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;
}
}