QuizU: Event handling in UI Toolkit

Hey everyone,

Welcome to our fifth post in a series of articles our team created to accompany the recently release QuizU sample project.

QuizU is a sample of an interactive quiz application that shows how UI Toolkit components can work together, leveraging various design patterns, in a small but functional game, complete with multiple screens and game flow management.

Even dispatch

Make sure you download the project from the Unity Asset Store and that you’re running on 2022 LTS to follow along.

This series of articles takes you through the sample, explaining how we implemented the project using UI Toolkit.

The previous posts in this series are here:

  1. Welcome to the new sample project QuizU
  2. QuizU: State pattern for game flow
  3. Managing menu screens in UI Toolkit
  4. Model View Presenter pattern

Today’s post is about event handling in QuizU. As with our previous posts, our aim here is to explain how we manage event handling in QuizU in a methodical way so that you can use the same steps in your own projects.

UI development is event-driven. It revolves around anticipating user actions and responding to them. These interactions can be as straightforward as clicking a button or entering text, or as complex as executing drag-and-drop operations.

Consider our interactive QuizU game. When a user clicks a button on the Game Screen, the UI responds by displaying a new question. The application flows through a sequence of events and corresponding responses.


Events use a publisher-subscriber model.

Registering events

Every user input – a mouse click, key press, or pointer action – is an event. You handle events by registering event callbacks to specific UI elements. These callbacks are functions that get executed when the event occurs. For example, you can register an action to a button’s ClickEvent so it executes some logic in response to a user clicking the button.

UI Toolkit uses a dedicated event system to make setting up events flexible and dynamic. An EventDispatcher listens for user interactions or script commands and then sends these events to the appropriate UI elements in your visual tree.

Setting up event handlers on UI elements often resembles something like this:

public class SomeClass()
{
	…

      // Associates UI elements with event handlers
	private void RegisterCallbacks()
	{
	    // Registers a callback that triggers when the slider value changes
	    m_Slider.RegisterCallback<ChangeEvent<float>>(SliderChangeHandler);

	    // Registers callback that triggers when the button is clicked
	    m_Button.RegisterCallback<ClickEvent>(ButtonClickHandler);

	    // Registers callback that triggers when the button's layout changes
	    m_Button.RegisterCallback<GeometryChangedEvent> (ButtonGeometryChangedHandler);
	}
       
       // Event handler that logs the new slider value when it changes
       private void SliderChangeHandler(ChangeEvent<float> evt)
       {
        Debug.Log("Slider value changed: " + evt.newValue);
       }

}

In this example, you identify specific elements that need interaction then register a callback to one of their UI Toolkit events. Here we use the RegisterCallback method from CallbackEventHandler to set up callbacks for a slider and a button:

  • When the user drags the slider, the UI Toolkit event system raises a ChangeEvent with a new float value. The SliderChangeHandler method then processes the float value and performs an action.

  • When the user presses the button and triggers the ClickEvent, a ButtonClickedHandler method executes in response.

  • When something changes the same button’s layout (position/width/height/transform), that triggers a GeometryChangedEvent. A different event handler can then respond. (e.g. adjust its text label, change color, etc.).

Note how elements can have more than one event, depending on what they need to do.

You can organize this according to the needs of your application. In this code snippet, we’ve grouped the RegisterCallback lines into a method for convenience; then we just need to invoke RegisterCallbacks when setting up the UI.

This is a small sampling of the events available. See the documentation for a complete Event reference and more about handling events.

Alternate callback syntax

In addition to using the RegisterCallback method, some events have alternative ways of setting up their handlers, as a matter of convenience.

  • ChangeEvents: When working with a UI element that has a mutable value like a Slider or TextField, we can use the RegisterValueChangedCallback method. You can then use this to set up the event handler for the above Slider’s ChangeEvent just a little differently. Use the RegisterValueChangedCallback method with a ChangeEvent where T is any struct type, like float, int, bool, etc.

  • ClickEvents: Buttons have an even more simplified way to handle click events. When a button is clicked, instead of using RegisterCallback, you can use the clicked property. This clicked property is short for the button’s clickable.clicked. Clickable is a manipulator available to every button that tracks mouse events. Using this syntax is similar to using System.Action.

// Alternative way to add callback to the ChangeEvent<float>

m_Slider.RegisterValueChangedCallback(SliderChangeHandler);

private void SliderChangeHandler(ChangeEvent<float> evt)
{
    Debug.Log("Slider value changed: " + evt.newValue);
}

// Alternative way to handle a ClickEvent on the Button

m_Button.clicked += ButtonClickHandler;

private void ButtonClickHandler()
{
    Debug.Log("Button was clicked!");
}

Using event data

Events can also send custom data to their listeners. This data can provide useful context. For example, a ClickEvent has data that includes properties like clickCount (how many times the mouse button was pressed) and mousePosition (where the mouse click occurred).

Consider a button in your UI. Let’s say you want to track the number of times it has been clicked and the position of the last click. The ClickEvent can assist with that:

public class SomeClass()
{
    int clickCount = 0;
    Vector2 lastClickPosition;

    private void RegisterCallbacks()
    {
        // ClickEvent callback for button
        m_Button.RegisterCallback<ClickEvent>(ButtonClickHandler);
    }

    private void ButtonClickHandler(ClickEvent evt)
    {
        clickCount = evt.clickCount;
        lastClickPosition = evt.mousePosition;

        // Perform your action here
        Debug.Log($"Button clicked {clickCount} times. Last click position: {lastClickPosition}");
    }
}

In the ButtonClickHandler method, we’re extracting the clickCount and mousePosition from the ClickEvent object. This information can then be used for debugging, analytics, or to drive gameplay mechanics.

Most UI events in Unity have their own specific data that you can use in context. For example, when the value of a Slider changes or when you type into a TextField, a ChangeEvent happens.

For a complete list of events and their associated data, see the Event Reference.

Event dispatch and propagation

Events are dispatched to a specific visual element using a propagation path, as seen in the EventDispatch demo scene.

An event traverses the visual tree in phases: trickling down, at the target, and bubbling up. See this manual page for more detail.

To handle events in the appropriate phase, the EventBase class includes two important properties:

  • target: This property refers to the visual element where the original event occurred. For instance, if the user clicks a button, the target of the ClickEvent would be that specific button.

  • currentTarget: As an event moves along the propagation path, the Event.currentTarget property updates to the element currently handling the event.

These properties can help determine how and when to handle an event. For example, a callback registered on a parent element could handle click events for several child elements.

By checking the target property of the event, you can determine which child element was clicked and then handle the event. Take a closer look at the EventDispatchDemo script for sample usage.


The EventDispatchDemo scene shows how events propagate.

Unregistering events

In most cases, the lifetime of your controller object either matches or outlasts that of the UI, making it unnecessary to unregister event handlers. The garbage collector will usually reclaim callbacks along with their associated visual elements without issue.

However, there are specific cases where unregistering events is important:

  • External Object References: If a callback refers to an external object that gets destroyed or reset (e.g. a game audio system or some other GameObject), unregister the event to avoid errors.

  • Context-Sensitive Buttons: For buttons that change their behavior based on the game’s context, unregister the existing callback and register a new action (e.g. a jump button that becomes an interact button).

  • Static Events: If a VisualElement has registered a callback to a static event, that event will continue to hold a reference to the callback even after the element is destroyed. Unregister the callback to free the object for garbage collection.

To handle these situations, you can create a method (UnregisterEvents in the below example) where you unregister the event handlers:

public class SomeClass()
{
    …

    private void UnregisterEvents()
    {
        // Slider callbacks
        m_Slider.UnregisterCallback<ChangeEvent<float>>(SliderChangeHandler);

        // ClickEvent callback for button
        m_Button.UnregisterCallback<ClickEvent>(ButtonClickHandler);
        // GeometryChangedEvent callback for button
        m_Button.UnregisterCallback<GeometryChangedEvent> (ButtonGeometryChangedHandler);
    }
}

Though unregistration is often not a requirement in this case, getting into the habit of cleaning up after yourself can be a good thing. Be aware of your project’s specific needs and unregister callbacks when necessary. For everything else, rely on the garbage collector.

The Event Registry pattern

Registering and unregistering a large number of callbacks, however, can be cumbersome.

A more maintainable solution is using a helper utility to manage your UI Toolkit events. This event registry can help you keep track of all the callbacks, making it easier to unregister them when needed.

To implement this, we create a class named EventRegistry that implements IDisposable. The interface just really means we need to add a public Dispose method like this:

public class EventRegistry : IDisposable
{
    // Single delegate to hold all unregister actions
    Action m_UnregisterActions;

    // Registers a callback for a specific VisualElement and event type (e.g. ClickEvent, MouseEnterEvent, etc.). 
    public void RegisterCallback<TEvent>(VisualElement visualElement, Action<TEvent> callback) where TEvent : EventBase<TEvent>, new()
    {
        EventCallback<TEvent> eventCallback = new EventCallback<TEvent>(callback);
        visualElement.RegisterCallback(eventCallback);

        m_UnregisterActions += () => visualElement.UnregisterCallback(eventCallback);
    }
    // Unregisters all callbacks by invoking the m_UnregisterActions delegate, then sets it to null.
    public void Dispose()
    {
        m_UnregisterActions?.Invoke();
        m_UnregisterActions = null;
    }
}

The Dispose method invokes the m_UnregisterActions delegate and then sets it back to null. This unregisters the callbacks all at once, ensuring that all callbacks in the EventRegistry are properly disposed of.

This is necessary when we want to clean up before deactivating or destroying a part of our user interface. One call to Dispose will unregister all of the event handlers.

This approach means that we don’t have to manually manage each event callback’s registration and deregistration. The EventRegistry class can do that for us, but we’ll need to adjust how to handle the registration.

Using the Event Registry

The EventRegistry class now lets us register callbacks for easier disposal later. This can prevent the need to unregister each callback individually. Even if you have many event handlers in your UI, disposing of them just requires one method call.

To use it, we need to adjust our workflow. In the above SomeClass example, we instantiate the EventRegistry class and use that instance to register each callback for its corresponding visual element:

EventRegistry m_EventRegistry = new EventRegistry();
m_EventRegistry.RegisterCallback<ClickEvent>(m_Button, ButtonClickHandler);

void ButtonClickHandler(ClickEvent evt)
{
    Debug.Log("Button clicked!");
}

When we no longer need the callbacks (e.g. when the specific UI is no longer in use), we can call Dispose from a reference to the EventRegistry.

This is public so it can happen within the original SomeClass script or from another controller script managing that one. In the QuizU project, the GameScreen class manages several other smaller UIs. It can dispose of all UI elements under its care when destroyed or disabled.

Extending the Registry

The EventRegistry works for UI Toolkit events that derive from EventBase.
We can make some adjustments to the EventRegistry to handle other events as well:

public class EventRegistry : IDisposable
{
    Action m_UnregisterActions;


    public void RegisterCallback<TEvent>(VisualElement visualElement, Action<TEvent> callback) where TEvent : EventBase<TEvent>, new()
    {
        EventCallback<TEvent> eventCallback = new EventCallback<TEvent>(callback);
        visualElement.RegisterCallback(eventCallback);

        m_UnregisterActions += () => visualElement.UnregisterCallback(eventCallback);
    }

    public void RegisterCallback<TEvent>(VisualElement visualElement, Action callback) where TEvent : EventBase<TEvent>, new()
    {
        EventCallback<TEvent> eventCallback = new EventCallback<TEvent>((evt) => callback());
        visualElement.RegisterCallback(eventCallback);

        m_UnregisterActions += () => visualElement.UnregisterCallback(eventCallback);
    }

    public void RegisterValueChangedCallback<T>(BindableElement bindableElement, Action<T> callback) where T : struct
    {
        EventCallback<ChangeEvent<T>> eventCallback = new EventCallback<ChangeEvent<T>>(evt => callback(evt.newValue));
        bindableElement.RegisterCallback(eventCallback);

        m_UnregisterActions += () => bindableElement.UnregisterCallback(eventCallback);
    }

    public void Dispose()
    {
        m_UnregisterActions?.Invoke();
        m_UnregisterActions = null;
    }
}
  • The overloaded RegisterCallback(VisualElement, Action) registers callbacks that don’t require event data. This can be handy if you want to add simple actions to a set of buttons. This allows you to use any kind of System.Action, even a lambda, as a callback. The EventRegistry will unregister those automatically in Dispose.

  • The RegisterValueChangedCallback method is used with elements that have changeable values, such as sliders or text fields. When the value of these elements changes, the event passes a new value as a parameter.

  • The Dispose method invokes the m_UnregisterActions delegate and then sets it back to null just like before. This unregisters the callbacks all at once, including callbacks from other types of events. One call to Dispose is still all we need to unregister all of the event handlers.

You can find this version of the EventRegistry in the QuizU sample and examples of how to use it throughout the project. This gives you a convenient way to manage your callbacks and dispose of them when they’re no longer needed. From simple button clicks to intricate UI interactions, consider using an EventRegistry as a flexible way to streamline callback management in your UI logic.

Then, you can worry less about how to track your event handlers, and instead spend your time on what’s important, creating engaging UI experiences for your players.

Thanks for reading!

5 Likes