Unity UI的鼠标悬停范围合并机制分析

机制介绍

测试场景说明

  1. 父物体Parent,显示为白色图片,有EventTrigger组件。
  2. 子物体位于Parent下,显示为粉红色图片。
  3. 当鼠标指针进入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>();

/// <summary>
/// Search the cache for currently active pointers, return true if found.
/// </summary>
/// <param name="id">Touch ID</param>
/// <param name="data">Found data</param>
/// <param name="create">If not found should it be created</param>
/// <returns>True if pointer is found.</returns>
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;
}

/// <summary>
/// Process movement for the current frame with the given pointer event.
/// </summary>
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;
}

// 反射获取 m_PointerData 字典
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
// ******************************************************************
//@file FlatEventTrigger.cs
//@brief 不依赖父子关系进行事件传递或投射检测范围扩大的高级EventTrigger
//@author yufulao, yufulao@qq.com
//@createTime 2025.02.20 22:25:09
// ******************************************************************

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; //同步信号量:子物体Exit时,是否投射到了父物体(父子物体部分重叠时,且子物体在父物体前面时,从重叠部分离开Child进入Parent时,检测投射Child会延迟,检测失效)
private bool _isHitChildOnPointExit; //同步信号量:父物体Exit时,是否投射到了子物体(父子物体部分重叠时,且子物体在父物体后面时,从重叠部分离开Child进入Parent时,检测投射Parent会延迟,检测失效)


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

/// <summary>
/// 手动设置ChildEventTrigger
/// </summary>
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();
}

/// <summary>
/// 添加子物体EventTrigger
/// </summary>
public void AddChildEventTrigger(FlatEventTrigger childEventTrigger)
{
childEventTriggerList.Add(childEventTrigger);
_childRectTransformList.Add(childEventTrigger.transform.GetComponent<RectTransform>());
childEventTrigger.AddChildPointAction(OnPointEnterChild, OnPointExitChild);
}

/// <summary>
/// 移除子物体EventTrigger
/// </summary>
public void RemoveChildEventTrigger(FlatEventTrigger childEventTrigger)
{
if (!childEventTrigger)
{
return;
}

childEventTriggerList.Remove(childEventTrigger);
_childRectTransformList.Remove(childEventTrigger.transform.GetComponent<RectTransform>());
childEventTrigger.RemoveChildPointAction(OnPointEnterChild, OnPointExitChild);
}

/// <summary>
/// 子物体触发OnPointEnter时
/// </summary>
private void OnPointEnterChild(BaseEventData eventData)
{
//变量均为父物体的
var pointerEventData = (PointerEventData)eventData;
var hitMe = IsHitMe(pointerEventData);

if (_isHitChildOnPointExit)
{
_isHitChildOnPointExit = false;
return;
}

if (hitMe)
{
return;
}

ExecuteOnPointerEnter(pointerEventData);
}

/// <summary>
/// 子物体触发OnPointExit时
/// </summary>
private void OnPointExitChild(BaseEventData eventData)
{
//变量均为父物体的
var pointerEventData = (PointerEventData)eventData;
var hitMe = IsHitMe(pointerEventData);
// Debug.Log($"{gameObject.name}_hitMe_OnPointExitChild: {hitMe}");
_isHitMeOnChildPointExit = hitMe;
if (hitMe)
{
return;
}

ExecuteOnPointerExit(pointerEventData);
}

/// <summary>
/// 派发PointerEnter事件
/// </summary>
private void ExecuteOnPointerEnter(PointerEventData pointerEventData)
{
_actionOnPointerEnterChild?.Invoke(pointerEventData); //处理自己是子物体
PublicExecute(EventTriggerType.PointerEnter, pointerEventData);
}

/// <summary>
/// 派发PointerExit事件
/// </summary>
private void ExecuteOnPointerExit(PointerEventData pointerEventData)
{
_actionOnPointerExitChild?.Invoke(pointerEventData); //处理自己是子物体
PublicExecute(EventTriggerType.PointerExit, pointerEventData);
}

/// <summary>
/// 初始化所有ChildEventTrigger
/// </summary>
private void InitAllChildEventTrigger()
{
if (childEventTriggerList.Count <= 0)
{
return;
}

foreach (var childEventTrigger in childEventTriggerList)
{
_childRectTransformList.Add(childEventTrigger.transform.GetComponent<RectTransform>());
childEventTrigger.AddChildPointAction(OnPointEnterChild, OnPointExitChild);
}
}

/// <summary>
/// 当前是否投射到了自己
/// </summary>
private bool IsHitMe(PointerEventData pointerEventData)
{
return _mineRectTransform.gameObject.activeInHierarchy && RectTransformUtility.RectangleContainsScreenPoint(_mineRectTransform, pointerEventData.position, null);
}

/// <summary>
/// 当前是否投射到了Child物体
/// </summary>
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;
}

/// <summary>
/// 设置子物体的OnPointerEnter和OnPointerExit事件
/// </summary>
private void AddChildPointAction(Action<PointerEventData> actionOnPointerEnter, Action<PointerEventData> actionOnPointerExit)
{
_actionOnPointerEnterChild += actionOnPointerEnter;
_actionOnPointerExitChild += actionOnPointerExit;
}

/// <summary>
/// 移除子物体的OnPointerEnter和OnPointerExit事件
/// </summary>
private void RemoveChildPointAction(Action<PointerEventData> actionOnPointerEnter, Action<PointerEventData> actionOnPointerExit)
{
_actionOnPointerEnterChild -= actionOnPointerEnter;
_actionOnPointerExitChild -= actionOnPointerExit;
}

/// <summary>
/// 空子物体的OnPointerEnter事件
/// </summary>
private static void NullChildPointerEnterAction(PointerEventData pointerEventData)
{
}

/// <summary>
/// 空子物体的OnPointerExit事件
/// </summary>
private static void NullChildPointerExitAction(PointerEventData pointerEventData)
{
}

/// <summary>
/// EventTrigger的Execute为private,需要手动转发事件
/// </summary>
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
// ******************************************************************
//@file FlatEventTriggerEditor.cs
//@brief FlatEventTrigger的Editor类
//@author yufulao, yufulao@qq.com
//@createTime 2025.02.20 22:36:39
// ******************************************************************

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的父子层级关系。
效果示例: