UE5 Common UI お試し ⑤ 複数の Widget ~ Visibility 制御 ~

上記の続きです。

今回は機能別サンプルでも紹介されている Popup のような、複数の Widget を扱う場合の最初のステップとして Visibility の制御を見ていきます。

Widget の作成

WBP_Popup という名前で作成しています。例によって CommonActivatableWidget を親にします。

内部はそれ程工夫はありませんが、Close Popup ボタンは通常の Button です。(CommonButtonBase ではない)

また、上部に CommonActionWidget だけ配置しており、ボタンではありませんが Back のナビゲーションを案内しています。

以下に主要な Common UI 関連の設定を貼ります。

WBP_Popup CommonActionWidget_87

まず、ActionWidget_BackIcon に関してですが、こちらは Back のアイコンを出したいだけの設定です。

つづいて WBP_Popup ですが、こちらは Back に反応するための Is Back Handler のチェックをしています。また、WBP_Menu では true になっていた Auto Activate ですが、今回は WBP_Menu から Activate を呼び出すため false になっています。

続いて Activated Visibility / Deactivated Visibility ですが、こちらはアクティベート時に表示、非アクティブ時には消すという Popup としてはそのままの挙動にしています。

グラフ

コメント通りです。初期の非表示はもう少しスマートな方法があるのかもしれませんが、機能別サンプルもこうなっていたので一旦こうしています。

ActivatedVisibility / DeactivatedVisibility

ここで先程出てきたプロパティの確認をしておきます。まず定義ですが CommonActivatableWidget.h ですね。

UPROPERTY(EditAnywhere, Category = Activation, meta = (InlineEditConditionToggle = "ActivatedVisibility"))
bool bSetVisibilityOnActivated = false;

UPROPERTY(EditAnywhere, Category = Activation, meta = (EditCondition = "bSetVisibilityOnActivated"))
ESlateVisibility ActivatedVisibility = ESlateVisibility::SelfHitTestInvisible;

UPROPERTY(EditAnywhere, Category = Activation, meta = (InlineEditConditionToggle = "DeactivatedVisibility"))
bool bSetVisibilityOnDeactivated = false;

UPROPERTY(EditAnywhere, Category = Activation, meta = (EditCondition = "bSetVisibilityOnDeactivated"))
ESlateVisibility DeactivatedVisibility = ESlateVisibility::Collapsed;

Editor の UI 通りです。
続いて、これらのプロパティが利用されている箇所を見ていきます。まずは予想しやすい OnActivated / OnDeactivated 時です。

void UCommonActivatableWidget::NativeOnActivated()
{
    if (ensureMsgf(bIsActive, TEXT("[%s] has called NativeOnActivated, but isn't actually activated! Never call this directly - call ActivateWidget()")))
    {
        if (bSetVisibilityOnActivated)
        {
            SetVisibility(ActivatedVisibility);
            UE_LOG(LogCommonUI, Verbose, TEXT("[%s] set visibility to [%s] on activation"), *GetName(), *StaticEnum<ESlateVisibility>()->GetDisplayValueAsText(ActivatedVisibility).ToString());
        }

        BP_OnActivated();
        OnActivated().Broadcast();
        BP_OnWidgetActivated.Broadcast();
    }
}
void UCommonActivatableWidget::NativeOnDeactivated()
{
    if (ensure(!bIsActive))
    {
        if (bSetVisibilityOnDeactivated)
        {
            SetVisibility(DeactivatedVisibility);
            UE_LOG(LogCommonUI, Verbose, TEXT("[%s] set visibility to [%d] on deactivation"), *GetName(), *StaticEnum<ESlateVisibility>()->GetDisplayValueAsText(DeactivatedVisibility).ToString());
        }

        // Cancel any holds that were active
        ClearActiveHoldInputs();

        BP_OnDeactivated();
        OnDeactivated().Broadcast();
        BP_OnWidgetDeactivated.Broadcast();
    }
}

上記を見ての通りですが、そもそもこの設定がなければ SetVisibility が呼び出されないため表示は一切変更されないことになります。

続いて以下の箇所でも利用されていますが、これは後ほど出てくるためここではスキップします。

void UCommonActivatableWidget::HandleVisibilityBoundWidgetActivations()
{
    ESlateVisibility OldDeactivatedVisibility = DeactivatedBindVisibility;
    OldDeactivatedVisibility = bSetVisibilityOnActivated && IsActivated() ? ActivatedVisibility : OldDeactivatedVisibility;
    OldDeactivatedVisibility = bSetVisibilityOnDeactivated  && !IsActivated() ? DeactivatedVisibility : OldDeactivatedVisibility;

最後に、一応 ESlateVisibility の確認をしておきます。(私はよく内容を忘れます。。)

UENUM(BlueprintType)
enum class ESlateVisibility : uint8
{
    /** Visible and hit-testable (can interact with cursor). Default value. */
    Visible,
    /** Not visible and takes up no space in the layout (obviously not hit-testable). */
    Collapsed,
    /** Not visible but occupies layout space (obviously not hit-testable). */
    Hidden,
    /** Visible but not hit-testable (cannot interact with cursor) and children in the hierarchy (if any) are also not hit-testable. */
    HitTestInvisible UMETA(DisplayName = "Not Hit-Testable (Self & All Children)"),
    /** Visible but not hit-testable (cannot interact with cursor) and doesn't affect hit-testing on children (if any). */
    SelfHitTestInvisible UMETA(DisplayName = "Not Hit-Testable (Self Only)")
};

WBP_PopupWBP_Menu のボタンから呼び出し表示できるようにします。

Widget の修正

ここまで利用してきた WBP_Menu からはレイアウトやボタンの機能を変えています。

CommonButton_Open1, 2, 3

これは CommonButtonBase で作成しています。

全て以下のように Triggering Input Action の設定はなく、Default Click Action に反応するようにしています。

いずれも WBP_PopupActivate Widget を呼び出すようになっています。

WBP_Popup の設定

前述の WBP_Popup 自体の設定と同じなのですが、ここでも Activated Visibility / Deactivated Visibility の指定をしないと有効にならなかったので一応 WBP_Menu に配置した側での設定も貼っておきます。

また、Popup はサイズはコンテンツに合わせていて、全画面にはしていません。(つまり、Popup が表示されても裏側の Menu が触れる状態)

現状の動作

ここまでの設定で Open Window ボタンを押すと Popup が表示され、Popup で Close Popup または Back に対応するキーを押すと Popup が消えるという動作になり意図通りです。

加えて、Popup 表示時に Back キーを押して消えるのは Popup だけであり、適切に Back キーの入力がルーティングされていることが確認できます。

ただ、現状だと Popup 表示時に Menu のボタンも依然としてされてしまいます。よって、ここからは「Popup 表示時 (Activated) には Menu は触れ無くする (Deactivated) 」部分を書いていきます。

やることは WBP_MenuEvent Construct から以下のノードを設定するだけです。

これだけでは意味が分からないので詳細を見ていきます。

SetBindVisibilities / BindVisibilityToActivation

いずれも CommonActivatableWidget の関数です。

まず SetBindVisibilities ですが、ここで入力で受け取った設定値を変数に入れています。

void UCommonActivatableWidget::SetBindVisibilities(ESlateVisibility OnActivatedVisibility, ESlateVisibility OnDeactivatedVisibility, bool bInAllActive)
{
    ActivatedBindVisibility = OnActivatedVisibility;
    DeactivatedBindVisibility = OnDeactivatedVisibility;
    bAllActive = bInAllActive;
}

続いて BindVisibility の対象となる Widet を登録します(今回の例でいえば WBP_Popup です)。
VisibilityBoundWidgets に重複無く追加した上で、対象の WidgetActivated, Deactivated イベントに自身の HandleVisibilityBoundWidgetActivations を関連付けています。そして最後に一度呼び出しています。

void UCommonActivatableWidget::BindVisibilityToActivation(UCommonActivatableWidget* ActivatableWidget)
{
    if (ActivatableWidget && !VisibilityBoundWidgets.Contains(ActivatableWidget))
    {
        VisibilityBoundWidgets.Add(ActivatableWidget);
        ActivatableWidget->OnActivated().AddUObject(this, &UCommonActivatableWidget::HandleVisibilityBoundWidgetActivations);
        ActivatableWidget->OnDeactivated().AddUObject(this, &UCommonActivatableWidget::HandleVisibilityBoundWidgetActivations);

        HandleVisibilityBoundWidgetActivations();
    }
}

上述の HandleVisibilityBoundWidgetActivations は以下です。上記の通りであれば、最初の一回以外は、登録した Widet の Activate/Deactivate に反応して呼び出されることになります。

void UCommonActivatableWidget::HandleVisibilityBoundWidgetActivations()
{
    ESlateVisibility OldDeactivatedVisibility = DeactivatedBindVisibility;
    OldDeactivatedVisibility = bSetVisibilityOnActivated && IsActivated() ? ActivatedVisibility : OldDeactivatedVisibility;
    OldDeactivatedVisibility = bSetVisibilityOnDeactivated  && !IsActivated() ? DeactivatedVisibility : OldDeactivatedVisibility;

    for (const TWeakObjectPtr<UCommonActivatableWidget>& VisibilityBoundWidget : VisibilityBoundWidgets)
    {
        if (VisibilityBoundWidget.IsValid())
        {
            if (bAllActive)
            {
                if (!VisibilityBoundWidget->IsActivated())
                {
                    SetVisibility(OldDeactivatedVisibility);
                    return;
                }
            }
            else 
            {
                if (VisibilityBoundWidget->IsActivated())
                {
                    SetVisibility(ActivatedBindVisibility);
                    return;
                }
            }
        }
    }

    SetVisibility(bAllActive ? ActivatedBindVisibility : OldDeactivatedVisibility);
}

私が混乱してきたので改めてですが、変数と Editor 上での関係です。

ActivatedVisibility
DeactivatedVisibility
ActivatedBindVisibility
DeactivatedBindVisibility
bAllActive
SetBindVisibilities で設定される

ややこしいのですが、上記の設定の場合、この Widet の自身の ActivatedVisibility/DeactivatedVisibility は直感的(アクティブで表示、非アクティブで非表示)ですが、BindVisibility の場合は全く反対の設定を行うことに留意します。Bind 対象の Widget の Active/Deactive に対応するためです。

続いて、最初の 3 行がどのように判定されるのかを簡単なデシジョンテーブルで書いてみました。

1 2 3 4
IsActivated() Y Y N N
bSetVisibilityOnActivated Y N - -
bSetVisibilityOnDeactivated - - Y N
OldDeactivatedVisibility ActivatedVisibility DeactivatedBindVisibility DeactivatedVisibility DeactivatedBindVisibility

簡単には、自身の Activated/Deactivated Visibility の設定が有効であれば、それと自身のアクティブ状態を対応させ、逆に無効であれば DeactivatedBindVisibility を優先するというものです。

さて、for 文を見る前に bAllActive を確認しておきます。

/** True if we should switch to activated visibility only when all bound widgets are active */
bool bAllActive = true;

VisibilityBoundWidgets が全て Activated の場合にのみ、この Widget の activated visibility を変えるということのようです。

改めて for 文以降のみ貼ります。

 for (const TWeakObjectPtr<UCommonActivatableWidget>& VisibilityBoundWidget : VisibilityBoundWidgets)
    {
        if (VisibilityBoundWidget.IsValid())
        {
            if (bAllActive)
            {
                if (!VisibilityBoundWidget->IsActivated())
                {
                    SetVisibility(OldDeactivatedVisibility);
                    return;
                }
            }
            else 
            {
                if (VisibilityBoundWidget->IsActivated())
                {
                    SetVisibility(ActivatedBindVisibility);
                    return;
                }
            }
        }
    }

    SetVisibility(bAllActive ? ActivatedBindVisibility : OldDeactivatedVisibility);

bAllActive の場合には、先程のコメントにあった通り、Activated ではない VisibilityBoundWidget を検出した時点で OldDeactivatedVisibility を適用し return しています。

逆に bAllActive でない場合には、一つでも Activated なものを検出すると、ActivatedBindVisibility を適用しています。(上記の設定でいえば、一つでも登録した Widget がアクティブになれば、自身は Not Hit-Testable にするということです)

最後の一文は for 文の条件節に引っかからなかった場合ですが、(有効な VisibilityBoundWidget が 0 の場合の除けば) bAllActivetrue で全ての登録 WidgetActivated だった場合と、 bAllActivefalse で、全ての登録 WidgetDeactivated の場合なので、この式で十分のように見えます。

現状の動作

この状態だと、Popup が出ている時に、Back キーは効くのですが、Close Popup ボタンが押せないという状態になります。

というのも、Set Bind VisibilitiesOn Activated VisibilityAll Children も含めてヒットしない設定にしているため、WBP_Menu の子として配置している WBP_Popup もヒットしなくなっています。

一方で、これを Self のみヒットしなくすると、結局 Open Window のボタンが押せるままになってしまうので意味がありません。

続く

ということで、長くなってしまったので一度切ります。。
上記を回避するために現状の WBP_MenuWBP_Popup の包含関係を修正し、一枚外側に Widget を入れて並列に並べることが必要になりそうです。

実は機能別サンプルの方は最初から最初からそうなっているのですが、今回は自分で再現するにあたり、自分が理解できている機能だけを追いながら組んでいったのですがそれがミスの原因になりました。