Unity UI的鼠标悬停范围合并机制分析 机制介绍 测试场景说明
父物体Parent,显示为白色图片,有EventTrigger组件。
子物体位于Parent下,显示为粉红色图片。
当鼠标指针进入Parent时,触发EventTrigger的PointEnter悬停事件,输出“Enter_Parent”。当鼠标指针移出Parent时,触发EventTrigger的PointExit事件,输出“Exit_Parent”。效果如下:
子物体的射线投射范围也属于父物体的范围 如果Child与Parent无重叠部分,当鼠标指针移入Child后,也会触发Parent的PointEnter事件。说明Child范围属于Parent的射线检测范围。
机制依赖严格父子关系 如果我们取消Parent与Child物体的父子关系,使得Parent与Child处于同级关系。 再次测试,发现机制失效了,说明该机制依赖父子关系。 此外,如果将Child作为Parent的父物体,测试结果同样是不能触发Parent的Point悬停事件。 总结,该机制需要依赖严格的父子关系,子物体才属于父物体的射线投射范围。两个物体同属一个父物体时,即两个物体同级,并不能触发该机制,此外父物体不属于子物体的射线投射范围。
父子悬停事件派发顺序
将Child的PointEnter和PointExit事件也加上,输出“Enter_Child”和“Exit_Child”。
将Parent与Child保持部分区域重叠,这样即可允许鼠标指针从重叠区域进入子物体Child,实现在不触发父物体Parent的PointExit事件的情况下,进入Child。
现象一:如果鼠标指针直接进入Child时,先触发子物体Child的PointEnter事件,再触发父物体Parent的PointEnter事件。鼠标指针离开Child时,也是先触发子物体Child的PointExit,再触发父物体Parent的PointExit。 现象二:如果先进入Parent再进入的Child,正常先触发父物体Parent的PointEnter事件,再触发子物体Child的PointEnter事件。但是,当鼠标指针从Child离开时,变成了先触发父物体Parent的PointExit事件,再触发子物体Child的PointExit事件。该顺序与前次测试的顺序刚好相反。
源码分析 悬停事件处理载体:组件StandaloneInputModule
Unity内部会跟踪鼠标当前正悬停在哪些UI物体上,在StandaloneInputModule的基类PointerInputModule中,维护一个当前悬停UI列表:m_PointerData。 每当鼠标移动时,Unity重新计算当前鼠标下的UI物体列表,并与上一帧的列表进行对比,决定谁Enter,谁Exit。具体来说:
如果某个UI物体不在上一帧的列表里,但出现在当前帧的列表里,它会触发PointerEnter。
如果某个UI物体在上一帧的列表里,但不在当前帧的列表里,它会触发PointerExit。
如果某个UI物体已经在上一帧的列表里,并且仍然在当前帧的列表里,就不会触发任何事件(保持Hover状态)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 public abstract class PointerInputModule : BaseInputModule { protected Dictionary<int , PointerEventData> m_PointerData = new Dictionary<int , PointerEventData>(); protected bool GetPointerData (int id, out PointerEventData data, bool create ) { if (!m_PointerData.TryGetValue(id, out data) && create) { data = new PointerEventData(eventSystem) { pointerId = id, }; m_PointerData.Add(id, data); return true ; } return false ; } protected virtual void ProcessMove (PointerEventData pointerEvent ) { var targetGO = (Cursor.lockState == CursorLockMode.Locked ? null : pointerEvent.pointerCurrentRaycast.gameObject); HandlePointerExitAndEnter(pointerEvent, targetGO); } }
m_PointerData的管理 在GetPointerData()内,如果m_PointerData已经有该ID,则直接返回data。 如果没有,且create == true,则新建PointerEventData并存入m_PointerData。
hovered的更新 在ProcessMove()内,调用基类BaseInputModule的HandlePointerExitAndEnter(),HandlePointerExitAndEnter()中,会触发PointerEnter和PointerExit事件,然后对hover列表进行添加和移除ui物体的操作。
机制验证 我们通过反射获取m_PointerData,然后经过我精心的log,输出data的hover列表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 private static void LogHoveredData (){ var inputModule = EventSystem.current.currentInputModule as StandaloneInputModule; if (!inputModule) { return ; } var pointerDataField = typeof (StandaloneInputModule).GetField("m_PointerData" , BindingFlags.NonPublic | BindingFlags.Instance); if (pointerDataField == null ) { return ; } if (pointerDataField.GetValue(inputModule) is not Dictionary<int , PointerEventData> pointerDataDict) { return ; } foreach (var (id, data) in pointerDataDict) { if (data.hovered.Count <= 0 ) { continue ; } var log = $"id: {id} , data.hovered: [" ; foreach (var hoveredObject in data.hovered) { log += $"{hoveredObject.name} , " ; } log += "]" ; Debug.Log(log); } }
现象一: 输出:id: -1, data.hovered: [Child, Parent, Canvas, ] 分析:直接进入Child时,加进List,此时List为[Child],然后向上递归查找所属父物体,找到Parent,不在List中,加进List,此时List为[Child, Parent],然后找到Canvas,不在List中,加进List,此时List为[Child, Parent, Canvas],无父物体ui,结束查找。最后List结果为[Child, Parent, Canvas]
现象二: 输出:id: -1, data.hovered: [Parent, Canvas, Child, ] 分析:进入Parent后,加进List,此时List为[Parent],然后向上递归查找所属父物体,找到Canvas,不在List中,加进List,此时List为[Parent, Canvas],无父物体ui,结束查找。接着进入Child,不在List中,加进List,此时List为[Parent, Canvas, Child],找到Parent,在List中,不处理,找到Canvas,在List中,也不处理,无父物体ui,结束查找。最后List结果为[Parent, Canvas, Child]
父子依赖带来的冲突问题 由于该机制依赖严格父子关系,但是ui的父子关系又决定着ui的前后遮挡关系,所以,对于需求:物体A在物体B前面,B又要作为A的射线投射的一部分。对于ui遮挡:必须要A作为B的子物体,或者A与B同级且A靠下。对于射线投射范围合并:必须保持A是B的父物体,这样的严格父子关系。冲突由此而来。 需求举例:爱心需要显示在展开栏前面,但是移出爱心进入展开栏后,不能触发移出爱心的PointExit事件,不然会折叠回展开栏,就需要展开栏也作为爱心的射线投射范围。
FlatEventTrigger方案 对于这种对父子物体的Enter和Exit事件触发顺序不敏感的需求,主要是解决ui遮挡与范围合并机制之间的物体层级冲突。我们通过精确控制PointEnter与PointExit的事件派发,实现了不依赖严格父子关系的鼠标悬停范围合并的解决方案:FlatEventTrigger 引入一对同步信号量以及重写EventTrigger的OnPointerEnter和OnPointerExit函数,达到精确控制悬停事件的触发与取消,以及父子链的范围递归合并机制。 FlatEventTrigger:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 using System;using System.Collections.Generic;using System.Linq;using UnityEngine;using UnityEngine.EventSystems;namespace Yu { public class FlatEventTrigger : EventTrigger { [SerializeField ] private List<FlatEventTrigger> childEventTriggerList = new List<FlatEventTrigger>(); private RectTransform _mineRectTransform; private readonly List<RectTransform> _childRectTransformList = new List<RectTransform>(); private Action<PointerEventData> _actionOnPointerEnterChild = NullChildPointerEnterAction; private Action<PointerEventData> _actionOnPointerExitChild = NullChildPointerExitAction; private bool _isHitMeOnChildPointExit; private bool _isHitChildOnPointExit; private void Awake () { _mineRectTransform = GetComponent<RectTransform>(); InitAllChildEventTrigger(); } public override void OnPointerEnter (PointerEventData pointerEventData ) { if (childEventTriggerList.Count <= 0 ) { ExecuteOnPointerEnter(pointerEventData); return ; } if (_isHitMeOnChildPointExit) { _isHitMeOnChildPointExit = false ; return ; } var hitChild = IsHitChild(pointerEventData); if (hitChild) { return ; } ExecuteOnPointerEnter(pointerEventData); } public override void OnPointerExit (PointerEventData pointerEventData ) { if (childEventTriggerList.Count <= 0 ) { ExecuteOnPointerExit(pointerEventData); return ; } var hitChild = IsHitChild(pointerEventData); _isHitChildOnPointExit = hitChild; if (hitChild) { return ; } ExecuteOnPointerExit(pointerEventData); } public void SetChildEventTrigger (List<FlatEventTrigger> eventTriggerList ) { if (childEventTriggerList.Count > 0 ) { foreach (var childEventTrigger in childEventTriggerList) { childEventTrigger.RemoveChildPointAction(OnPointEnterChild, OnPointExitChild); } } childEventTriggerList.Clear(); _childRectTransformList.Clear(); if (eventTriggerList == null ) { return ; } foreach (var childEventTrigger in eventTriggerList) { childEventTriggerList.Add(childEventTrigger); } InitAllChildEventTrigger(); } public void AddChildEventTrigger (FlatEventTrigger childEventTrigger ) { childEventTriggerList.Add(childEventTrigger); _childRectTransformList.Add(childEventTrigger.transform.GetComponent<RectTransform>()); childEventTrigger.AddChildPointAction(OnPointEnterChild, OnPointExitChild); } public void RemoveChildEventTrigger (FlatEventTrigger childEventTrigger ) { if (!childEventTrigger) { return ; } childEventTriggerList.Remove(childEventTrigger); _childRectTransformList.Remove(childEventTrigger.transform.GetComponent<RectTransform>()); childEventTrigger.RemoveChildPointAction(OnPointEnterChild, OnPointExitChild); } private void OnPointEnterChild (BaseEventData eventData ) { var pointerEventData = (PointerEventData)eventData; var hitMe = IsHitMe(pointerEventData); if (_isHitChildOnPointExit) { _isHitChildOnPointExit = false ; return ; } if (hitMe) { return ; } ExecuteOnPointerEnter(pointerEventData); } private void OnPointExitChild (BaseEventData eventData ) { var pointerEventData = (PointerEventData)eventData; var hitMe = IsHitMe(pointerEventData); _isHitMeOnChildPointExit = hitMe; if (hitMe) { return ; } ExecuteOnPointerExit(pointerEventData); } private void ExecuteOnPointerEnter (PointerEventData pointerEventData ) { _actionOnPointerEnterChild?.Invoke(pointerEventData); PublicExecute(EventTriggerType.PointerEnter, pointerEventData); } private void ExecuteOnPointerExit (PointerEventData pointerEventData ) { _actionOnPointerExitChild?.Invoke(pointerEventData); PublicExecute(EventTriggerType.PointerExit, pointerEventData); } private void InitAllChildEventTrigger () { if (childEventTriggerList.Count <= 0 ) { return ; } foreach (var childEventTrigger in childEventTriggerList) { _childRectTransformList.Add(childEventTrigger.transform.GetComponent<RectTransform>()); childEventTrigger.AddChildPointAction(OnPointEnterChild, OnPointExitChild); } } private bool IsHitMe (PointerEventData pointerEventData ) { return _mineRectTransform.gameObject.activeInHierarchy && RectTransformUtility.RectangleContainsScreenPoint(_mineRectTransform, pointerEventData.position, null ); } private bool IsHitChild (PointerEventData pointerEventData ) { foreach (var childRectTransform in _childRectTransformList) { if (!childRectTransform) { continue ; } if (!childRectTransform.gameObject.activeInHierarchy) { continue ; } if (RectTransformUtility.RectangleContainsScreenPoint(childRectTransform, pointerEventData.position, null )) { return true ; } } return false ; } private void AddChildPointAction (Action<PointerEventData> actionOnPointerEnter, Action<PointerEventData> actionOnPointerExit ) { _actionOnPointerEnterChild += actionOnPointerEnter; _actionOnPointerExitChild += actionOnPointerExit; } private void RemoveChildPointAction (Action<PointerEventData> actionOnPointerEnter, Action<PointerEventData> actionOnPointerExit ) { _actionOnPointerEnterChild -= actionOnPointerEnter; _actionOnPointerExitChild -= actionOnPointerExit; } private static void NullChildPointerEnterAction (PointerEventData pointerEventData ) { } private static void NullChildPointerExitAction (PointerEventData pointerEventData ) { } private void PublicExecute (EventTriggerType id, BaseEventData eventData ) { foreach (var ent in triggers.Where(ent => ent.eventID == id && ent.callback != null )) { ent.callback.Invoke(eventData); } } } }
Editor类:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 using UnityEditor;using UnityEditor.EventSystems;using UnityEngine;namespace Yu { [CustomEditor(typeof(FlatEventTrigger)) ] public class FlatEventTriggerEditor : EventTriggerEditor { private SerializedProperty _childEventTriggerList; protected override void OnEnable () { base .OnEnable(); _childEventTriggerList = serializedObject.FindProperty("childEventTriggerList" ); } public override void OnInspectorGUI () { serializedObject.Update(); EditorGUILayout.PropertyField(_childEventTriggerList, new GUIContent("子物体列表" )); serializedObject.ApplyModifiedProperties(); base .OnInspectorGUI(); } } }
FlatEventTrigger使用说明 添加子物体EventTrigger (允许静态添加与动态添加两种方式) 静态添加: 手动拖拽概念上的子物体即可: 动态添加: 游戏运行中,动态创建的子物体,可以通过FlatEventTrigger的SetChildEventTrigger()、AddChildEventTrigger()、RemoveChildEventTrigger(),来管理FlatEventTrigger组件的子物体列表。
内部处理 设置好子物体列表后,即可达到与父子关系的EventTrigger一样的效果,即使该父子物体只是概念上的父子物体,并不需要遵循Hierarchy的父子层级关系。 效果示例: