Propagating Unity UI Cancel Events

Recently I ran into a problem with Unity’s UI system. The problem is simple: How do I close a menu using a controller?

To give an example, a main menu hierarchy will look something like this:

  • Canvas
    • Main menu
      • New game button, options menu button, etc
    • Options menu
      • Resolution dropdown, VSync options, etc
    • … other menus, like level select

If the player opens the options menu from the main menu, then hits the “B” button on their controller, they should be taken back to the main menu. Easily achieved by polling for Input.GetButtonDown in some component on the menu, right? Well, this works for most situations, but it falls over when some component needs to handle the cancel itself. For example, if a dropdown is open and cancel is pressed, then only the dropdown should close, not the menu.

The Unity UI InputModule already has a field which takes a cancel axis - can we leverage that? There is some limited support for doing so, but it turns out to be somewhat more complicated than at first glance.

Unity UI components receive events by implementing C# interfaces - for example, the ISubmitHandler. When a component implementing ISubmitHandler is selected and the submit button is pressed, then the OnSubmit method on the component is called.

There’s a corresponding interface for receiving cancel events: ICancelHandler. However, remember that the event only goes to the selected gameobject. When we’re in a menu, the selected gameobject is a button, or a text input field, or some other Unity UI component, not the menu itself.

How, then, can we propagate the event up to the menu? Perhaps we’ll find a clue about how to do so if we take a look at how the cancel event is fired. To do so, we’ll dive into the Unity UI source.

Let’s start with the StandaloneInputModule, where the cancel button identifier is stored. The variable holding it is m_CancelButton, and that variable is used on this line:

if (input.GetButtonDown(m_CancelButton))
    ExecuteEvents.Execute(eventSystem.currentSelectedGameObject, data, ExecuteEvents.cancelHandler);

The input module is calling ExecuteEvents.Execute on a single gameobject. Wouldn’t it be nice if, instead of stopping at the current gameobject, we could propagate the event upward in the hierarchy? Well, browsing around the source for the ExecuteEvents class, you’ll notice something interesting: it already a method named ExecuteHierarchy!

This method does almost exactly what we want. Instead of considering only currentSelectedGameObject, it iterates upwards in the hierarchy until it finds something that implements the given event type, and executes the event on that object. If we replace the above ExecuteEvents.Execute call to ExecuteEvents.ExecuteHierarchy, cancel events should propagate upwards from whatever we have selected and end up at the menu controller gameobject. When the menu controller receives the event, it can close itself.

Since the StandaloneInputModule exists in the Unity UI dll, there’s no easy way to just replace that one line, but there is still a way out. You can copy the source for the StandaloneInputModule, rename the class (e.g. to CustomStandaloneInputModule), and then replace the execute call within your custom class.

This does mean we have some ongoing maintenance to keep up with new Unity UI versions. Rewired, a popular input package, already uses this approach (albeit for different reasons), so I feel safe using it myself.

The menu can now receive the cancel inputs, but we still have the problem of dropdowns (and other implementers of ICancelHandler) eating cancel events before they can make it to the menu.

Since all components on a selected gameobject receive the OnCancel call, we could write a component derived from EventTrigger which propagates the event. But if we do that, we’re back to the same problem as listening for an Input.GetButtonDown call: hitting cancel will close both open dropdowns and the menu. Sometimes the event shouldn’t propagate.

Unfortunately, I haven’t found a generic solution to this - but there is a solution. We can special-case propagations per component. For example, for dropdowns, we can write an EventTrigger-derived component to propagate cancel events only when the dropdown is closed:

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;

public class DropdownCancelEventPropagator : EventTrigger
    private bool dropdownIsOpen = false;

    public override void OnSelect(BaseEventData eventData)
       // When the dropdown element itself is selected, the items in the dropdown aren't displayed
       dropdownIsOpen = false;

    public override void OnDeselect(BaseEventData eventData)
        // When the dropdown element is deselected, either we don't care about what happens to the event or the menu is down
        dropdownIsOpen = true;

    public override void OnCancel(BaseEventData eventData)
        if (!dropdownIsOpen)
            ExecuteEvents.ExecuteHierarchy(transform.parent.gameObject, eventData, ExecuteEvents.cancelHandler);

Attach this component to your dropdowns, and you’ll see that events are propagated correctly. When the dropdown is open, the cancel event closes the dropdown and does not propagate, and when the dropdown is closed, the event propagates. For other components implementing ICancelHandler, you’ll need to implement similar EventTriggers to propagate events when it is desirable to do so.

It’s kind of unfortunate that we have to do this ourselves. Ideally, OnCancel could return a boolean to indicate that a component is handling the cancel event and ExecuteHierarchy would propagate accordingly. Unfortunately, built-in support is essential for this; to implement this as a plugin, we’d have to re-implement a lot of the EventSystem framework to do this and create new versions of all the components that use the normal events. I’ve experimented with replacing the Unity UI dll using the instructions in their bitbucket Readme, but I’ve found that it leads to a high maintenance cost when you update Unity versions.

Thus, I’ll stick with this solution for now. If you have questions or suggestions, drop me a comment below.