【UE5】複数の EditorUtilityWidget を組み合わせてひとつのウィジェットを構築する方法【C++】

本稿では Unreal Engine 5 の C++ を使って複数の子 Editor Utility Widget(エディタユーティリティウィジェット、以下 EUW) を、ひとつの親 EUW にはめ込んで表示する方法について共有します。
EUW のマニアックな使い方に興味があるエンジニア向けです。

動作環境
Unreal Engine 5.4.3 および 5.4.4
Windows 11 Home
Visual Studio 2022

UI の作り方(一般論)

EUW に限らず、UI は親とは別に作った子のパーツを組み合わせて作る方が柔軟性が高くなります。
そうすることで、必要に応じて好きなタイミングで別のパーツに差し替えることができるようになるためです。

これをブループリントでやるのは簡単です。

ブループリントでは誰でも簡単にいじれてしまうので、C++ で作ろうとしたときに参考資料が見つからずに困りました。
上記画像の「Construct 子パーツ名」ノードに該当する関数が C++ 側にはありません。

Add Child Panel ノードは、私が C++ 側で作った UFUNCTION なので気にしないでください。
ネットで見つかる情報の問題点

細かい事は割愛しますが、あれこれ試行錯誤したところ、子の EUW を構築するときは、FSoftObjectPath などを使ってアセットとしてロードする必要があることが分かりました。

FSoftObjectPath pathA(FString(TEXT("/Script/Blutility.EditorUtilityWidgetBlueprint'/Game/Test/ChildPartsA.ChildPartsA'")));
UObject* partsA_widget = pathA.TryLoad();

ネットだと、UGameplayStatics::SpawnObject() でいけるという話も出て来るのですが、うまく行きませんでした。
この関数では引数のチェックをした後に NewObject しているだけなので、NewObject で良さそうに思ったのですが、本稿のケースでは NewObject では対応できません。

実際の作り方
親の EUW

親用の EUW を新規作成します。
CanvasPanel(親用)の下に、子を入れるための別のキャンバスパネル(子用)を追加します。
キャンバスパネルを使う理由は、GetLayout / SetLayout 関数を使って EUW のフォームサイズに合わせてレイアウト調整を行えるようにするためです。

子は「どこに配置するものなのか?」が分かりやすいよう名前を変更していますが、全てキャンバスパネルです。
子の EUW

子用の EUW を新規作成します。
本稿では3つの子を用意しています。

CanvasPanel の下にボーダーを追加し、子 EUW の表示範囲が分かりやすいよう色を着けています。

コード

EUW に設定する親クラスを C++ で作成します。
Editor Utility Widget はエディタ用の機能なので、エディタモジュールにソースファイルを作成します。
プロジェクト名は EUWTest です。

エディタモジュール用の *.Build.cs に以下のモジュールを追加します。
private でも public でも、どっちでも良いです。

“UMG”
“UMGEditor”
“Blutility”

//  Copyright dokuro.moe

// EUWTestEditor.h

#pragma once

#include "CoreMinimal.h"
#include "Editor/Blutility/Classes/EditorUtilityWidget.h"
#include "Editor/UMGEditor/Public/WidgetBlueprint.h"

#include "EUWTestEditor.generated.h"

class UCanvasPanel;
class UEditorUtilityWidgetBlueprint;

// 子 A の EUW
UCLASS(BlueprintType)
class EUWTESTEDITOR_API UEUWChildPartsA
  : public UEditorUtilityWidget {
  GENERATED_BODY()
};

// 子 B の EUW
UCLASS(BlueprintType)
class EUWTESTEDITOR_API UEUWChildPartsB
  : public UEditorUtilityWidget {
  GENERATED_BODY()
};

// 子 C の EUW
UCLASS(BlueprintType)
class EUWTESTEDITOR_API UEUWChildPartsC
  : public UEditorUtilityWidget {
  GENERATED_BODY()
};

// 親の EUW
UCLASS(BlueprintType)
class EUWTESTEDITOR_API UEUWParentWidget final
  : public UEditorUtilityWidget {
  GENERATED_BODY()
public:
  virtual void NativeTick(const FGeometry& Geometry, float DeltaTime) override;
private:
  bool IsPassedNativeTick() const { return this->bPassedNativTick; }
  bool ConstructChildParts();
  void AdjustLayout();

  // 親用キャンバスパネルを C++ 側で使えるようにバインド
  UPROPERTY(EditAnywhere, meta = (BindWidget))
  UCanvasPanel* CanvasPanel{nullptr};

  UPROPERTY()
  TArray<UEditorUtilityWidgetBlueprint*> ChildParts;

  bool bPassedNativTick{false};

  FVector2f PrevSize;
  FVector2f PrevPosition;

  FVector2f Size;
  FVector2f Position;
};
//  Copyright dokuro.moe

// EUWTestEditor.cpp

#include "EUWTestEditor.h"
#include "Components/CanvasPanel.h"
#include "Components/CanvasPanelSlot.h"
#include "Editor/Blutility/Classes/EditorUtilityWidgetBlueprint.h"

void UEUWParentWidget::NativeTick(const FGeometry& Geometry, float DeltaTime) {
  this->PrevSize     = this->Size;
  this->PrevPosition = this->Position;

  // 親ウィンドウの表示位置とサイズをキャッシュ
  this->Size     = Geometry.GetLocalSize();
  this->Position = Geometry.AbsolutePosition;  

  if (this->PrevSize != this->Size) {
    // ウィンドウサイズが変わったら子を配置し直す
    this->AdjustLayout();
  }
  if (!this->bPassedNativTick) {
    // 子を作成(一回だけ実行)
    this->ConstructChildParts();
  }
}

bool UEUWParentWidget::ConstructChildParts() {
  if ( this->IsPassedNativeTick()) { return false; }
  if (!IsValid(this->CanvasPanel)) { return false; }

  // パスは適宜修正してください。
  FSoftObjectPath pathA(FString(TEXT("/Script/Blutility.EditorUtilityWidgetBlueprint'/Game/Test/ChildPartsA.ChildPartsA'")));
  UObject* partsA_widget = pathA.TryLoad();
  if (!IsValid(partsA_widget)) { return false; }

  FSoftObjectPath pathB(FString(TEXT("/Script/Blutility.EditorUtilityWidgetBlueprint'/Game/Test/ChildPartsB.ChildPartsB'")));
  UObject* partsB_widget = pathB.TryLoad();
  if (!IsValid(partsB_widget)) { return false; }

  FSoftObjectPath pathC(FString(TEXT("/Script/Blutility.EditorUtilityWidgetBlueprint'/Game/Test/ChildPartsC.ChildPartsC'")));
  UObject* partsC_widget = pathC.TryLoad();
  if (!IsValid(partsC_widget)) { return false; }

  this->ChildParts.Add(CastChecked<UEditorUtilityWidgetBlueprint>(partsA_widget));
  this->ChildParts.Add(CastChecked<UEditorUtilityWidgetBlueprint>(partsB_widget));
  this->ChildParts.Add(CastChecked<UEditorUtilityWidgetBlueprint>(partsC_widget));

  TArray<UWidget*> all_children = this->CanvasPanel->GetAllChildren();
  if (all_children.Num() != this->ChildParts.Num()) { return false; }

  TArray<UCanvasPanel*> panels = {
    CastChecked<UCanvasPanel>(all_children[0]),
    CastChecked<UCanvasPanel>(all_children[1]),
    CastChecked<UCanvasPanel>(all_children[2])
  };

  for (int32 i = 0; i < panels.Num(); i++) {
    UEditorUtilityWidgetBlueprint* widget_bp = this->ChildParts[i];
    widget_bp->CreateUtilityWidget();

    UWidget     * widget = widget_bp->GetCreatedWidget();
    UCanvasPanel* panel  = panels[i];
    panel->AddChild(widget);
  }

  this->AdjustLayout();
  this->bPassedNativTick = true;

  return true;
}

void UEUWParentWidget::AdjustLayout() {
  TArray<UWidget*> all_children = this->CanvasPanel->GetAllChildren();
  if (all_children.Num() != this->ChildParts.Num()) { return; }

  TArray<UCanvasPanel*> panels = {
    CastChecked<UCanvasPanel>(all_children[0]),
    CastChecked<UCanvasPanel>(all_children[1]),
    CastChecked<UCanvasPanel>(all_children[2])
  };
  const TArray<FVector2D> positions = {
    FVector2D(                      0.f,                       0.f),
    FVector2D(                      0.f, this->Size.Y * 0.4f + 4.f),
    FVector2D(this->Size.X * 0.5f + 2.f, this->Size.Y * 0.4f + 4.f)
  };
  const TArray<FVector2D> sizes = {
    FVector2D(this->Size.X             , this->Size.Y * 0.4f),
    FVector2D(this->Size.X * 0.5f - 2.f, this->Size.Y * 0.6f - 4.f),
    FVector2D(this->Size.X * 0.5f - 2.f, this->Size.Y * 0.6f - 4.f)
  };

  for (int32 i = 0; i < panels.Num(); i++) {
    UWidget         * widget      = this->ChildParts[i]->GetCreatedWidget();
    UCanvasPanel    * panel       = panels[i];
    UCanvasPanelSlot* panel_slot  = CastChecked<UCanvasPanelSlot>(panel->Slot);
    UCanvasPanelSlot* child_panel = CastChecked<UCanvasPanelSlot>(widget->Slot);

    // 子をはめ込む親のキャンバスパネル
    {
      FAnchorData layout = panel_slot->GetLayout();
      layout.Offsets.Left   = positions[i].X;
      layout.Offsets.Top    = positions[i].Y;
      layout.Offsets.Right  = positions[i].X + sizes[i].X;
      layout.Offsets.Bottom = positions[i].Y + sizes[i].Y;
      panel_slot->SetLayout(layout);
    }

    // 子のキャンバスパネル
    {
      FAnchorData layout = child_panel->GetLayout();
      layout.Offsets.Left   = 0.f;
      layout.Offsets.Top    = 0.f;
      layout.Offsets.Right  = sizes[i].X;
      layout.Offsets.Bottom = sizes[i].Y;
      child_panel->SetLayout(layout);
    }
  }
}

コードの通りですが、子 EUW は親を表示してからロードして表示しています。
EUW を表示中に PartsC を PartsD と入れ替えたい…という要望が出て来ても、上記コードのように処理すれば実現できます。

ポインタの有効性チェックを飛ばしているところがあります。

if (pointer) とか if (IsValid(pointer)) とか

ゲームとして実行するコードや、クラッシュして作業データが失われる可能性のあるツール用のコードなら、もっと厳しくチェックするのですが、動作検証用のコードなのでいちいちチェックしません。

EUW の親クラスを変更

親と全ての子の親クラスを↑のコードで定義したクラスに変更します。

動作確認

※本稿のアイキャッチ画像は Microsoft Copilot で生成したものです。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です