ランタイム用のゲームのインベントリをUI ToolKitで作成しておったのですが、UI ToolkitにスクリプトからUIコンポーネントを動的に追加してもなぜか追加されないという問題に遭遇。
インベントリの方のUI構造
テンプレートの方のUI構造(内部構造は今回の主題とは関係ない)
インベントリにアイテムボタンを動的に追加するスクリプトはこれ。
VisualElement groupBox;
VisualElement root;
private void Start()
{
// UIDocumentのルートVisualElementを取得
root = GetComponent<UIDocument>().rootVisualElement;
// インベントリgroupBoxの参照を取得
groupBox = root.Q<GroupBox>("Inventory");
}
// インベントリにアイテムを追加する処理(シーン上のイベント経由で呼び出される)
public void RegistItem(string itemName)
{
// テンプレートをインスタンス化
var instantiateButtonTemplate = buttonTemplate.Instantiate();
// ボタン要素を取得
var button = instantiateButtonTemplate.Q<Button>("ItemPanel");
// テキストを設定
button.text = itemName;
// クリックイベントを登録
button.clicked += () => Debug.Log($"Item '{itemName}' clicked!");
// GroupBoxに追加
groupBox.Add(button); ←※ここでなぜかbuttonがgroupBoxに追加されない
}
UI ToolKit DebuggerでUIツリーをデバッグ表示してみたが、表示上見えてないとかではなく、そもそも追加されていない。
テンプレートのUIレイアウトがおかしいのか?などと色々探っても原因不明。
実に奇妙奇怪である。
しかし最終的に原因が判明。カラクリがわかれば簡単な話だった。
(というかカラクリというほどのものでもない)
別のスクリプトだったので上のコードには含まれていないが、Start()~RegistItem()の間に、UIDocumentのゲームオブジェクトの表示・非表示を切り替える処理が入っていたのだ。その時点でUIDocumentのUIツリーは再構築され、UIのインスタンスも新しく作られてすり替わっている。
スクリプト内でのrootは古いインスタンスを参照したままのままだったので、古いインスタンスにボタンを追加しようとしていたのだ。
古いインスタンスにbuttonを追加しようとしていた
UI ToolKit Debuggerにプレビューされているのは新しいインスタンス
そのため、デバッガー上で表示されている新しいUIインスタンス上には何も追加された様子が見られなかったのだ。
下のようなスクリプトで試した。ReferenceEqualsで参照が同じかどうかを確かめている。
// ルートVisualElementを取得
var root1 = GetComponent<UIDocument>().rootVisualElement;
var root2 = GetComponent<UIDocument>().rootVisualElement;
Debug.Log("root1とroot2は" + (ReferenceEquals(root1, root2) ? "同じインスタンス" : "違うインスタンス"));
// 無効化して再度有効化する
gameObject.SetActive(false);
gameObject.SetActive(true);
// 再度ルートVisualElementを取得
var root3 = GetComponent<UIDocument>().rootVisualElement;
Debug.Log("root1とroot3は" + (ReferenceEquals(root1, root3) ? "同じインスタンス" : "違うインスタンス"));
▼実行結果(ゲームオブジェクトを再有効化した後はインスタンスが変わっていることがわかる)
そもそも上の例では、Start()でrootVisualElementを取得しているのが間違いであった。
OnEnableで取得するようにすればゲームオブジェクトを再度有効化した際にも呼び出されるので新しいインスタンスを常に取れるし、または操作直前(上の上のスクリプトでいえばRegistItem()内)でrootVisualElementを取るようにすればこういう錯誤は起こらない。
※以下は「どういう場合にUIDocumentが再構築されるのか」を質問したときのChatGPTの回答です。
以下の操作を行った場合、UIDocument 内の UI ツリーが再生成されます:
VisualTreeAsset を再設定した場合
UIDocument.visualTreeAsset プロパティに新しい VisualTreeAsset を設定すると、UI ツリーが再構築されます。
例:
var newVisualTree = Resources.Load<VisualTreeAsset>("NewUI");
GetComponent<UIDocument>().visualTreeAsset = newVisualTree;
UIDocument コンポーネントを有効化/無効化した場合
Unity のコンポーネントの有効化/無効化 (SetActive(false) → SetActive(true) や、enabled プロパティの切り替え) によっても、UIDocument が再初期化されます。
UIDocument がアタッチされているゲームオブジェクトが破棄されたり、非アクティブ化→再アクティブ化されたりした場合も再ロードが発生します。
例:
gameObject.SetActive(false);
gameObject.SetActive(true);
シーンのリロードによって、シーン内のすべてのオブジェクトが再生成されます。この場合、UIDocument も含めて UI の状態が初期化されます。
例:
SceneManager.LoadScene(SceneManager.GetActiveScene().name);
Unity エディタで Play Mode を一旦停止し、再度開始した場合、すべてのコンポーネントが再初期化されるため、UIDocument も再ロードされます。
エディタ上で以下の操作を行うと、再ロードがトリガーされる場合があります:
UIDocument に関連するアセット(VisualTreeAsset や StyleSheet)を更新した場合。
インスペクタ上で UIDocument のプロパティを変更した場合。
スクリプトやユーザー操作で、UIDocument をアタッチしたゲームオブジェクトを動的に削除し、新たに生成した場合。
例:
Destroy(gameObject);
// 新しいオブジェクトを生成して UIDocument を再アタッチ
var newGameObject = new GameObject("NewUIDocument");
newGameObject.AddComponent<UIDocument>();
再ロードが発生した場合、rootVisualElement の参照が変わります。以下のようにチェックすることで再ロードの発生を確認できます。
private VisualElement cachedRoot;
void Start()
{
var root = GetComponent<UIDocument>().rootVisualElement;
cachedRoot = root;
}
void Update()
{
var currentRoot = GetComponent<UIDocument>().rootVisualElement;
if (!ReferenceEquals(cachedRoot, currentRoot))
{
Debug.Log("UIDocument has been reloaded.");
cachedRoot = currentRoot;
}
}
通常の運用では UIDocument の再ロードは頻繁には起こりませんが、動的な UI の切り替えやオブジェクトのアクティブ化/非アクティブ化が絡む場合に注意が必要です。再ロードが発生する可能性のある状況を把握しておくと、意図しない UI の挙動を防ぐことができます。