All clicks produce mouse, pointer and click events, difficult to reason about

Hi,

(SOLUTION AT END OF THREAD)

I’m having a lot of difficulty with the fact that the UIElements buttons respond both to mouse events and pointer events and synthesized clicks, and I’m struggling to filter events when they should be conditionally blocked.

I have a popup shown at the deepest child of a hierarchy. The top level of the hierarchy fills the screen and contains a trickle-down event listener. If you click outside the popup, I want to close the popup and stop propagation. It’s more detailed in unimportant ways. I actually have logic for which clicks should be allowed to click through, and which clicks should just close it.

The problem is that if you click on a Button after the popup, that Button responds both to mouse events, pointer events, and synthetic click events, each in down, up, over, enter and out versions. This isn’t trivial to filter because the events arrive separately.

If if I block all mouse events by sending them into a black hole at the outermost trickle-down opportunity, and I block the pointer down event, and keep state to also ignore the next pointer up event, the button is still responding.

Is there a way to make UI Elements run only with pointer events? These mouse events arriving in a pile together with pointer events make it difficult to reason about event handling, and it’s even more difficult because these are synthesized into click events (mouse down + up). So you have three event streams arriving for the same user operation.

What am I missing? There must be a way to turn it off and only do pointer events universally. The only thing I want from a mouse is its scroll wheel. Everything else has a pointer version that’s just fine.

1 Like

Just to add a bit of detail, this is the hierarchy. The trickle-down event handler is attached to the root element. The button that keeps responding is inside one of the lower TemplateContainers.

7451153--914117--upload_2021-8-26_18-12-47.png

In the root element, I monitor pointer down/up events as an early warning system. I also block all mouse events for the test. StopImmediatePropagation() is called on all mouse events.

In PointerDownEarlyWarning, I decide if I want to block the click completely. If I block it, I also save state so that the later PointerUpEvent is also discarded.

But even with all this suppression, the button far below top level trickle-down handler keeps responding. I have other controls of my own that only respond to pointer events, and they’re correctly blocked.

There must be some additional kind of psychic signal that buttons are listening to.

Final detail to show that I should be correctly blocking pointer down and up events as well. When the popup is shown, I right now cancel the event and close the popup instead. I also remember to cancel the following pointer up event.

Yet with all mouse events blocked and pointer down and up suppressed, buttons are still responding.

It’s the ClickEvent. So this is getting really difficult, because it’s synthesized from MouseDown and MouseUp, and even though I’m suppressing the mouse events, they’re still being synthesized into click events. So they’re impervious to filtering.

So I’ve now tried to also suppress the click event while we’re canceling the popup, but the problem is that it arrives as the very last event, after all my logic. So the only way available for me to suppress it is with timers, which are evil in UI logic.

Is there a correct way to do this? It’s no problem to make a popup permanently cover the screen so the next click outside closes. But if you want any of those clicks to go through and do actual work, there seems to be no way to do it.

Even if you try to stop the clicks at the outermost parent, Unity is still synthesizing events from all the events you’re blocking, and they fire anyway. But the synthesized events arrive so late, and so situationally, that you don’t know whether you should block them.

I’m missing some critical insight.

The question I can’t work out is who is synthesizing the click event. Even with all mouse events and all pointer events going into a StopImmediatePropagation black hole from trickle-down handlers on the outermost container, ClickEvents are still firing. It seems impossible to stop them.

And since they arrive after all other mouse/pointer releases, the only way to suppress them conditionally is to use a timer. That seems incredibly ugly. It’s not possible to simply have ClickEvent take the place of mouse/pointer release, because it doesn’t always fire. I’d basically have to say that “if a ClickEvent is received within X milliseconds of a click I’ve decided should be discarded, also discard the ClickEvent”. That’s pretty smelly.

A ray of sunshine is that both mouse/point up events, and ClickEvents arrive on the same frame. So in place of a timer, it’s possible for PointerUpEvent to decide that ClickEvent should be suppressed, record the frame number, and if a ClickEvent is received on the same frame number, it’s also thrown away.

The order of events is deterministic, so this can work as a way to reintegrate the events into a single stream of decision making.

OK, I have a solution which is robust, if awkward. Posting here if anyone finds themselves in the situation.

In summary, the goal is to be able to click outside a popup and have it automatically close, but choose whether that same click is discarded or can do real work. This comes from menu buttons that pop up a menu. You should be able to select other menus without having to first click outside the existing menu. But all other outside clicks should just close the popup and do nothing else.

The difficulty is that mouse events, pointer events, and click events arrive as separate streams. This code is a way to correlate them back together so the decision to block a click applies to all permutations of the same event.

In a UI class that owns the root element, I create trickle-down event handlers on mouse/point down/up, and on click. This is the earliest it’s possible to monitor pointer events. Based on a list of VisualElements that are “allowed”, I decide whether to allow the click to go through or not.

If we decide to block the click, we go through two phases. The first is that we count the mouse downs we’ve decided to block (can be fingers, and more than one). If we realize on mouse-up that these were blocked mouse-downs, we take a note of the current frame number (Time.frameCount). If we receive a ClickEvent or MouseUpEvent on this exact frame, we also block it. In effect, we allow through clicks on all the allowed elements. All other clicks are solely used to close the popup.

This works because the order events arrive in is highly deterministic. You always receive pointer events, then mouse events, and finally synthesized events like clicks. And they arrive within the same frame. So it’s possible to throw the keys over the metal detector from PointerUpEvent to MouseUpEvent and ClickEvent using the frame number, and reason about all the events together.

Here is the static UI class that owns the root element

    public static void Init()
    {
        uiGameObj = new GameObject();
        uiGameObj.name = "UI Container";

        rootDoc = uiGameObj.AddComponent<UIDocument>();
        root = rootDoc.rootVisualElement;
        root.StretchToParentSize();
        root.name = "rootElement";

        root.RegisterCallback<PointerDownEvent>(PointerDownEvent, TrickleDown.TrickleDown);
        root.RegisterCallback<MouseDownEvent>(MouseDownEvent, TrickleDown.TrickleDown);

        root.RegisterCallback<PointerUpEvent>(PointerUpEvent, TrickleDown.TrickleDown);
        root.RegisterCallback<MouseUpEvent>(MouseUpEvent, TrickleDown.TrickleDown);
        root.RegisterCallback<ClickEvent>(ClickEvent, TrickleDown.TrickleDown);

        rootDoc.panelSettings = panelSettings; // From elsewhere
    }

    ///// DOWN EVENTS /////

    private static HashSet<int> suppressedClicks = new HashSet<int>();

    private static int blockMouseUpFrame = 0;
    private static int blockClickFrame = 0;

    private static void PointerDownEvent(PointerDownEvent evt)
    {
        if (suppressedClicks.Count != 0 || ShouldBeBlocked(evt.position)) {
            suppressedClicks.Add(evt.pointerId);
            evt.StopImmediatePropagation();
            if (suppressedClicks.Count == 1) {
                // Only cancel popup on first blocked pointer down event
                Popup.Cancel();
            }
        }
    }

    private static void MouseDownEvent(MouseDownEvent evt)
    {
        if (suppressedClicks.Count != 0) {
            evt.StopImmediatePropagation();
        }
    }

    private static bool ShouldBeBlocked(Vector3 pos)
    {
        if (!Popup.IsActive()) {
            return false;
        }

        foreach (var element in Popup.AllowedFrontClicks()) {
            if (element.worldBound.Contains(pos)) {
                return false;
            }
        }
        return true;
    }

    ///// UP EVENTS /////

    private static void PointerUpEvent(PointerUpEvent evt)
    {
        if (suppressedClicks.Contains(evt.pointerId)) {
            suppressedClicks.Remove(evt.pointerId);
            blockMouseUpFrame = Time.frameCount;
            blockClickFrame = Time.frameCount;
            evt.StopImmediatePropagation();
        }
    }

    private static void MouseUpEvent(MouseUpEvent evt)
    {
        if (blockMouseUpFrame == Time.frameCount) {
            evt.StopImmediatePropagation();
        }
    }

    private static void ClickEvent(ClickEvent evt)
    {
        if (blockClickFrame == Time.frameCount) {
            evt.StopImmediatePropagation();
        }
    }

And then the popup itself provides a list of “safe” VisualElements that are allowed to receive direct clicks. All other clicks will only close the popup and be discarded.

    public static IEnumerable<VisualElement> AllowedFrontClicks()
    {
        yield return frame;

        foreach (var front in frontClickItems) {
            yield return front;
        }
    }
2 Likes