← 返回索引

范围:输入路由的修改与排查。所有鼠标、键盘、焦点与覆盖层事件都从 InteractionManager 这一个入口进入框架。内容包括:一次点击按优先级依次经过的捕获、挂起、外部关闭、覆盖层与根树;命中检测 H0/H1/L1/H3/L3 各层的职责与覆写选择;焦点与鼠标捕获状态的持有者与清除时机。


InteractionManager

整个框架的单一事件入口点。Screen 仅转发事件;所有路由逻辑都在该类中。

public class InteractionManager {
    private final LayoutElement<?> root;        // 根元素树
    private final OverlayManager overlays;      // 覆盖层系统(借用引用)
    private Focusable focusedTarget;            // 当前焦点(单一目标)
    private Element<?> pressOwner;              // 当前按下目标
    private Element<?> mouseCapture;            // 拖拽捕获所有者
    private int suspendLevel;                   // 全局挂起计数器
    public static InteractionManager active;    // 全局单例
}

初始化

在 Screen 子类中:

@Override protected void init() {
    overlays = new OverlayManager();
    interactionManager = new InteractionManager(root, overlays);
}

// 将所有输入事件转发给 interactionManager
@Override public boolean mousePressed(double mx, double my, int btn) {
    return interactionManager != null && interactionManager.mousePressed(new MousePressedEvent(mx, my, btn, Modifiers.NONE));
}

碰撞检测层级

碰撞检测按由简至繁的层次划分:

层级方法描述
H0Element.rectHit(el, mx, my)纯几何矩形命中。不可覆盖。
H1el.shapeHit(mx, my)有效形状命中。唯一可覆盖的点。 默认 = H0。用于动画几何形状。
L1Element.isHit(el, mx, my)形状命中 ∩ 祖先裁剪边界。统一谓词,用于分发门控、外部关闭和块查询。
H3Hit.hitOrder/hitPath/topmost单树搜索。hitOrder = 广度优先(所有命中,自上而下)。hitPath = 深度优先(根→最深)。topmost = 路径终点。
L3topmostAnywhere(mx, my)全 UI 搜索:先覆盖层树(topmostInTree),再根树。

关键不变量: isHit = shapeHit(mx, my) && isMouseWithinClipBounds(mx, my)。裁剪边界检查沿父元素链向上查找 HIDDEN 或 SCROLL 容器,并验证点在其边界内。


鼠标分发优先级

mousePressed 按固定优先级顺序处理(第一个 true 获胜):

0. BLOCK_RIGHT 守卫        — 如果 pressOwner 有 BLOCK_RIGHT,右键事件不再派发
1. mouseCapture            — 被捕获元素获得所有事件(忽略碰撞检测)
2. suspendLevel > 0        — 跳过所有内容,返回 false
3. dismissOverlaysOutside  — 在分发前通知覆盖层外部交互
4. L3 最高命中             — 整个 UI 的单次碰撞检测(工具提示关闭遍历)
5. 焦点转移                — 命中焦点元素 → 获取焦点;未命中焦点族 → 取消焦点
6. 按下链                  — 从命中元素沿父链向上,消费者记为 pressOwner
7. 捕获簿记                — pendingCapture 激活为 mouseCapture

mouseReleased / mouseDragged 遵循相同的主干: 捕获 → 挂起 → 关闭 → 枝叶→主干通知式分发。关键区别: 松开与拖拽会先检查 pressOwner 的 CAPTURE_RELEASE / CAPTURE_DRAG 位,命中则路由给 pressOwner。松开到 pressOwner 与捕获所有者时调用 pressReleased(e, hit),hit 是管理器当场按完整悬停仲裁算出的命中;广播路径调用 mouseReleased(e),不带命中信息。

mouseScrolled 与按下同构: 捕获 → 挂起 → 关闭 → 命中元素优先、沿父链回退、第一个消费滚轮的即截断。起点是 topmostAnywhere(...);它为 null 时(点在容器空白处)改用 Hit.deepestLayoutAt(root, mx, my),取矩形含点的最深布局容器。


焦点系统

public interface Focusable {
    boolean isFocused();
    void defocus();  // "放弃与位置无关的事件接收权"
}
  • 单一目标: 任何时候最多一个 focusedTarget
  • 点击外部取消焦点: 点击焦点族外部触发 defocus()
  • 焦点族: FocusableElement.focusFamily 是一个 Set<Element<?>>(基于标识)。族内的元素被点击时不会触发取消焦点。用于: TextBox 的上下文菜单、AutoCompleteBox 的下拉菜单。
  • 键盘路由: keyPressed / keyReleased / charTyped / preeditUpdated 经 dispatchKeyboard 发送给 resolveKeyboardReceiver() 解析出的接收者,主路径是 focusedTarget(须在焦点上且是 KeyboardReceiver);否则从焦点目标(无则从根)沿父链向上找第一个仍在焦点上的 KeyboardReceiver,只取沿途第一个
  • 无注册表: 焦点通过 setFocusTarget / clearFocusTarget 设置/清除,使用标识比较

鼠标捕获

拖拽操作的两阶段激活:

// 阶段 1: 请求(在 mousePressed 中)
InteractionManager.active.requestMouseCapture(this);  // pendingCapture = this

// 阶段 2: 激活(在下一次 mousePressed 命中时)
// mouseCapture 变为激活状态,捕获坐标被记录

两阶段是硬约束,不是约定:requestMouseCapture 只写 pendingCapture,真正被提升为 mouseCapture 只发生在 mousePressed 的按下链末尾。所以在悬停回调(mouseEntered)里调它不会生效,即 pendingCapture 只会等待下一次任意按下才激活,此时还会将捕获错误地交给当时已不在悬停的元素。需要「按下落住」的独占(拖动)必须在 mousePressed 中申请。

releaseMouseCapture() 除了把 mouseCapture 置空,还会清零 captureButton、captureStartX/Y、capturePrevX/Y、captureLastX/Y 这七个字段,不清零会使 captureDX() 之类访问器读到上一次手势的残留值。

捕获期间:

  • mousePressed, mouseDragged, mouseScrolled 直接发送给捕获所有者(无碰撞检测)
  • pressReleased(e, hit) 发送给捕获所有者然后自动释放
  • 根据光标是否位于捕获所有者边界内,合成 mouseEntered/mouseExited 事件

查询 API: isMouseCaptured(), mouseCaptureOwner(), captureStartX/Y(), captureDeltaX/Y(), captureDX/DY()。


分发数据系统

事件对象

  • 鼠标链路使用 MousePressedEvent MouseReleasedEvent MouseDraggedEvent MouseScrolledEvent;
  • 键盘链路使用 KeyPressedEvent KeyReleasedEvent CharTypedEvent PreeditUpdatedEvent;
  • 悬停链路使用 HoverEvent。

各输入回调接收对应事件对象,从事件对象字段读取坐标、按钮、修饰键与滚轮偏移。

命中数据与路径

Hit 是命中实现,定义子节点候选、同层命中顺序、最深路径与路径终点。核心方法如下:

方法作用
Hit.candidates(LayoutElement<?>)返回可见且可布局的直接子元素候选。
Hit.hitOrder(LayoutElement<?>, mx, my)广度优先:当前层所有命中的自上而下顺序。
Hit.hitPath(LayoutElement<?>, mx, my)深度优先:根到最深元素的路径。
Hit.topmost(LayoutElement<?>, mx, my)路径终点元素,无则 null。

InteractionManager 持有根树与浮层管理器两个字段:root 与 overlays。

浮层树在 OverlayTree 内放入 Layer 记录列表,推入、移除与查询都直接作用于列表。

全 UI 命中

InteractionManager.topmostAnywhere(mx, my) 是唯一的全 UI 命中入口。

命中顺序为先询问 overlays.tree().topmostInTree(mx, my),未命中时再计算 Hit.topmost(root, mx, my)。

分发遍历

事件遍历的核心依据是命中元素与其父链,而非容器内部递归。规则如下:

  • LayoutElement 只负责绘制顺序与布局,不保存输入分发状态。
  • InteractionManager 的四条鼠标分发链路先依次执行捕获、挂起、外部关闭、全 UI 命中、焦点转移,再按命中链通知元素。
  • dispatchOverlays 对每个可见且命中的层根调用元素的事件回调;返回 true 即不可继续分发。

外部点击关闭

浮层树使用 OverlayTree.Layer 记录。每条记录只包含该层根元素与 dismissOnOutsideClick 标志。

InteractionManager.dismissOverlaysOutside 会遍历层列表,依次判断是否需要发送外部交互通知。

tick 链

tick 与输入是两条互不交叉的链:ui.tick 包下的 UiTickDriver 与 TickWalker 把渲染帧的真实耗时按固定步长累加,每一步遍历根树与浮层树并调用 Element.tick(),不经过上述任何一条输入链路。InteractionManager 没有每帧入口,原先那个按 20 TPS 转发同一条遍历的旧方法已删除(删除记录见 改动记录),驱动器只借用本类读取两棵树。步长取值、遍历口径与不变量以 生命周期系统 为准。

相关文档