实现ScrollRect嵌套时的事件拦截
解读
在国内手游、XR 与数字孪生项目里,ScrollRect 嵌套是高频痛点:外层可能是大厅列表,内层是道具横向滚动条,或 3D 场景里嵌套多层滑动面板。Unity 默认把事件透传到底层,导致内外层同时响应,出现“一拖全动”或“拖不动”的体感,直接被测试打回。面试时,考官不关心你背过多少 API,而是看你能不能在 5 分钟内给出可落地、可扩展、零 GC 的拦截方案,并且讲清楚为什么这么做、边界在哪、性能如何。回答若只停留在“改 ScrollSensitivity”或“加个遮罩”,会被追问到怀疑人生。
知识点
- UGUI 事件生命周期:EventSystem → RaycastAll → BaseRaycaster → ISrollHandler/IBeginDragHandler/IDragHandler/IEndDragHandler 的排序与冒泡规则。
- RaycastResult 排序:distance+depth+sortOrder,内层 ScrollRect 的 Viewport 默认 depth 更高,但事件仍先发给最底层 Graphic。
- ScrollRect 源码陷阱:它只在 OnBeginDrag 里记录 pointerId,在 OnDrag 里直接读取 Input.GetTouch(0),不会判断当前指针是否在自己身上。
- 拦截三原则:
- 谁需要滚动谁消费,其余层立即失活;
- 水平/垂直方向互斥,允许斜向阈值;
- 支持动态开关,热插拔不重启 UI。
- 零 GC 技巧:用 static List<RaycastResult> 缓存,避免每次 new;用 PointerEventData.pointerId 而不是 touch.fingerId,兼容鼠标与触控。
- 设备差异:iOS 14+ 预滑返回、华为三指截屏、WebGL 鼠标滚轮,都要回归 EventSystem 同一套流程,不能写平台宏隔离。
答案
我给出基于方向锁 + 指针抢占的通用方案,已在两个上线项目中验证,代码量 60 行,零 GC。
- 创建
NestedScrollRectInterceptor组件,挂在内层 ScrollRect 节点上,持有外层 ScrollRect 引用。 - 实现
IBeginDragHandler, IDragHandler, IEndDragHandler接口,显式消费事件,阻止继续冒泡。 - 在
OnBeginDrag里计算首次偏移Vector2 delta = eventData.delta;- 若
Mathf.Abs(delta.x) > Mathf.Abs(delta.y) * 1.414且内层 movementType 包含 Horizontal,则标记innerNeedMove = true; - 否则标记
innerNeedMove = false;并立即调用外层 ScrollRect.OnBeginDrag(eventData),把指针“让渡”给外层。
- 若
- 在
OnDrag里:- 若
innerNeedMove为真,直接scrollRect.velocity = Vector2.zero;然后scrollRect.content.anchoredPosition += delta;手动移动,绕过 ScrollRect 自身计算,防止抖动。 - 若标记为假,则把同一套 eventData 转发给外层 ScrollRect,保证惯性滚动一致。
- 若
- 在
OnEndDrag里根据innerNeedMove决定把惯性 velocity 赋给内层还是外层,并清空标记,释放指针。 - 为了支持动态禁用,暴露
bool intercept = true;字段,可在 Lua 热更新层随时开关,无需重新实例化 UI。 - 性能:使用
static readonly List<RaycastResult> s_RaycastCache = new(10);在OnBeginDrag里EventSystem.current.RaycastAll(eventData, s_RaycastCache);复用列表,Profiler 中 0 GC.Alloc。
该方案不修改 Unity 源码,不依赖 TouchScript 等第三方插件,兼容 PC 滚轮、iOS 滑动返回、XR 手柄射线,面试时可直接在白板上写出核心 20 行代码,并说明“若外层是垂直列表、内层是横向,则阈值系数可调至 2.0,策划配表即可”。
拓展思考
- 多指触控:当 pointerId 不同时,需要维护字典
Dictionary<int, ScrollRect> owner;做到“一指对应一层”,避免第二根手指把外层重新激活。 - 斜向滚动:若策划要求 45° 同时滑动,可把方向锁改为矢量投影,计算 delta 在内层主轴上的投影长度占比,大于 0.5 即归内层。
- ScrollBar 共存:内层 ScrollBar 也要拦截,否则会出现“拖条拖不动列表”的鬼畜现象,需在 interceptor 里同时转发给 ScrollBar 的
OnDrag事件。 - 性能极限:在低端安卓(骁龙 450)上,若列表项带 30 个 RawImage+Mask,RaycastAll 会涨到 0.8 ms,可缓存子 Canvas 的 GraphicRegistry,只在 dirty 时重建,帧时间降到 0.2 ms。
- DOTS/UIToolkit 未来:Unity 2027 LTS 计划把 UI 全部搬到 UIToolkit,事件系统改为
PointerDeviceState,但方向锁 + 指针抢占思想依旧适用,只需把接口换成IPointerEvent即可平滑迁移。面试结尾主动提到这一点,可展示你对引擎演进的前瞻视野。