Unity6 Game Development · Lesson 34

UI Toolkit runtime controls and document lifetime

Bind named UXML controls from a UIDocument and release listeners on disable.

Unity 6 Editor walkthrough

The Unity script needs the Editor and the setup listed below. The separate C# companion practice runs in SharpRune without UnityEngine.

A runtime visual tree has an owner and a lifetime

UIDocument turns an assigned VisualTreeAsset into a runtime visual tree associated with Panel Settings. The Button and Label queried from that tree are not GameObjects and are not the uGUI controls from the earlier lesson. ToolkitCounterProbe therefore works through UnityEngine.UIElements and exact element names. The tree can be recreated, so a retained element reference must be treated as belonging to one document generation. The component’s counter is ordinary state that can survive a disable/enable transition even when a new tree is bound. Keeping those lifetimes separate prevents an apparently visible UI from quietly retaining listeners on obsolete elements.

Create the two named elements before debugging a query

The supplied UXML can be saved as CounterPanel.uxml or created equivalently in UI Builder. Its Name values are the query contract; the visible text Add one is not the identifier used by Q<Button>. Assign the imported VisualTreeAsset to the UIDocument Source Asset and assign a Panel Settings asset so the document has a runtime presentation context. Keep UIDocument enabled before binding. This baseline needs no uGUI Canvas and uses a UI Toolkit-only scene. If the Game view is empty, verify those asset assignments and panel configuration before changing callback code or assuming that a missing element name is a pointer-input problem.

<ui:UXML xmlns:ui="UnityEngine.UIElements">
    <ui:VisualElement style="padding-top: 24px; padding-left: 24px;">
        <ui:Label name="count" text="Count: 0" />
        <ui:Button name="increment" text="Add one" />
    </ui:VisualElement>
</ui:UXML>

Detach old elements before binding the current root

Rebind begins by detaching any previous click callback and clearing both cached elements. It then requires Play mode, an active controller, an enabled UIDocument and a nonnull rootVisualElement. Missing names produce an explicit error and leave no partial subscription. A successful lookup attaches Increment and renders the current count. OnDisable uses the same Detach routine, pairing ownership at one boundary. The public context action also supports a document independently rebuilt or toggled without this controller cycling: call it after the new root is ready. The code does not poll for a root or claim a cached Button will automatically follow a replacement tree.

    [ContextMenu("Rebind Current Document (Play Mode)")]
    public void Rebind()
    {
        Detach();
        if (!Application.isPlaying || !isActiveAndEnabled) return;
        UIDocument document = GetComponent<UIDocument>();
        if (document == null || !document.isActiveAndEnabled)
        { Debug.LogWarning("Enable the assigned UIDocument before rebinding.", this); return; }
        VisualElement root = document.rootVisualElement;
        if (root == null)
        { Debug.LogWarning("UIDocument has no current root; rebind after it is ready.", this); return; }
        incrementButton = root.Q<Button>("increment");
        countLabel = root.Q<Label>("count");
        if (incrementButton == null || countLabel == null)
        { Debug.LogError("UXML needs Button increment and Label count.", this); Detach(); return; }
        incrementButton.clicked += Increment;
        Render();
    }

Rendering and pointer routing are different setup checks

For the stated standalone baseline, Active Input Handling is Input Manager (Old), and UI Toolkit’s default runtime event system supplies mouse events without a scene EventSystem. A project using the newer Input System needs the compatible package and its project-wide UI actions configured deliberately. Mixed uGUI scenes with an EventSystem introduce an input-module choice; leaving conflicting modules is not equivalent to the simple baseline. A visible button proves that a tree was presented, while a changed count proves that a click reached the bound element and invoked this callback. Inspect these facts separately instead of treating every inert button as an event-subscription defect.

Trace one explicit subscription lease

The portable Binding has an attached flag, so two Enable calls install only one ordinary C# event handler. One supplied click increments Count to one. Disable removes that handler; another supplied click leaves the printed count at one. Re-enabling attaches it again and the next click reaches two. This establishes the model’s subscription lease, not UI Toolkit’s event routing or document lifecycle. There is no UIDocument, VisualTreeAsset, panel or pointer in that program. Keep its deterministic event test independent from the real Game-view click test, particularly when diagnosing a recreated root that the ordinary event source has no way to discover.

Practice: rebuild the document and bind its new root

Click twice in a configured Play session, then disable and re-enable the whole document GameObject. Confirm that the current tree displays the retained count and responds once per click. Next test toggling UIDocument alone while this controller stays enabled, and invoke Rebind Current Document after its replacement root is ready. That independent case makes the explicit recovery path meaningful. Deliberately rename increment in a copied UXML asset to inspect the missing-name error, then restore it. Keep this separate from changing the input backend so the experiment can distinguish stale tree ownership, query mismatch and missing pointer events.

  • Hint: do not keep using a cached element from a previous root generation.
  • Hint: when UIDocument alone changes lifetime, explicitly rebind or cycle the controller after the new root is ready.

Practice: verify lease boundaries without calling it a UI test

Extend the portable exercise with Disable before Enable, repeated Disable, and several independent subscribers. Predict which count changes on each supplied click, then ensure one subscriber detaching does not remove another subscriber’s handler. For a richer model, attach a generation token to an event source and reject callbacks from a superseded source. That is still ordinary subscription logic, not a renderer test. The real engine source was API-reviewed and parsed as C#9, and the console lease was actually executed; no Unity Editor input or runtime panel execution is claimed. Use both kinds of evidence before declaring a production UI interaction complete.

  • Hint: ordinary event handler ownership belongs to the subscriber that attached it.
  • Hint: verify names, document generation and input routing independently in the Editor.
Runs in Unity 6 Editor

Unity Editor example

Set up in Unity 6

  1. Create a UI Toolkit VisualTreeAsset named CounterPanel.uxml in UI Builder, containing Button with Name increment and text Add one, plus Label with Name count. The article includes equivalent UXML source.
  2. Create a Panel Settings asset. Add an active GameObject with UIDocument, assign CounterPanel.uxml as Source Asset and the Panel Settings asset, then attach ToolkitCounterProbe.cs to that same GameObject.
  3. For this standalone UI Toolkit baseline, use no uGUI Canvas or EventSystem and set Project Settings > Player > Active Input Handling to Input Manager (Old), restarting when requested. UI Toolkit’s default runtime event system handles the mouse.
  4. If the project instead uses Input System Package (New), install the compatible com.unity.inputsystem package and configure its project-wide UI Point/Click/Submit/Cancel actions. If an EventSystem exists for mixed uGUI, choose the matching input module; do not leave conflicting modules.
  5. Enter Play mode, click Add one in Game view, then disable and re-enable the GameObject. The controller re-queries the current tree and retains its component count across that activation.
  6. Keep UIDocument enabled before binding. If UIDocument alone is disabled/re-enabled or its tree is replaced without this controller cycling, call Rebind Current Document after the new root is ready, or cycle the controller too; stale element references do not follow the replacement.

ToolkitCounterProbe.cs

Download Unity script ↗
using UnityEngine;
using UnityEngine.UIElements;
[RequireComponent(typeof(UIDocument))]
public sealed class ToolkitCounterProbe : MonoBehaviour
{
    private Button incrementButton;
    private Label countLabel;
    private int count;
    private void OnEnable() { Rebind(); }
    [ContextMenu("Rebind Current Document (Play Mode)")]
    public void Rebind()
    {
        Detach();
        if (!Application.isPlaying || !isActiveAndEnabled) return;
        UIDocument document = GetComponent<UIDocument>();
        if (document == null || !document.isActiveAndEnabled)
        { Debug.LogWarning("Enable the assigned UIDocument before rebinding.", this); return; }
        VisualElement root = document.rootVisualElement;
        if (root == null)
        { Debug.LogWarning("UIDocument has no current root; rebind after it is ready.", this); return; }
        incrementButton = root.Q<Button>("increment");
        countLabel = root.Q<Label>("count");
        if (incrementButton == null || countLabel == null)
        { Debug.LogError("UXML needs Button increment and Label count.", this); Detach(); return; }
        incrementButton.clicked += Increment;
        Render();
    }
    private void OnDisable() { Detach(); }
    private void Detach()
    {
        if (incrementButton != null) incrementButton.clicked -= Increment;
        incrementButton = null;
        countLabel = null;
    }
    private void Increment() { count++; Render(); }
    private void Render() { if (countLabel != null) countLabel.text = "Count: " + count; }
}

What to observe in Unity

The configured runtime document displays Count: 0 and increments once per click. Re-enabling binds the current visual tree; missing element names produce an explicit error rather than a null-reference failure.

The console practice below isolates the C# logic. It does not run a Unity scene or emulate Unity engine callbacks.

C# companion practice

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;

var source = new ClickSource();
var counter = new Binding(source);
counter.Enable(); counter.Enable(); source.Click(); Console.WriteLine(counter.Count);
counter.Disable(); source.Click(); Console.WriteLine(counter.Count);
counter.Enable(); source.Click(); Console.WriteLine(counter.Count);
sealed class ClickSource { public event Action? Clicked; public void Click() => Clicked?.Invoke(); }
sealed class Binding
{
    private readonly ClickSource source; private bool attached;
    public int Count { get; private set; }
    public Binding(ClickSource source) { this.source = source; }
    public void Enable() { if (attached) return; source.Clicked += Add; attached = true; }
    public void Disable() { if (!attached) return; source.Clicked -= Add; attached = false; }
    private void Add() { Count++; }
}
Open C# practice

C# practice output

1
1
2
Try this
Re-query a recreated visual tree and pair listener attachment with detachment.

Further reading