功能概述
某些时候,我们想要实现这样一种功能:
点击屏幕上的一块区域,该区域触发一个事件,同时位于该区域以下的UI组件依然能够接收的到点击事件。
比如在游戏的背包系统中,点击某个道具会显示道具的信息 Tips ,在 Tips 存在的时候,点击第二个道具,打开第二个道具的 Tips ,同时关闭第一个道具的 Tips 。
(图片来自网络)
如果采用 UGUI 框架来实现的话,我们知道一个 UI 组件想要接收点击事件,需要勾选其 “Raycast Target” 选项,但一旦勾选这个选项,位于其下的 UI 组件就无法接收到点击事件,故需要通过其它手段来实现这样的功能。
实现思路
特殊逻辑实现
对于上面提到的背包 Tips 需求,其实可以直接在逻辑代码里面做处理来实现:
- 首先取消 Tips 的 “Raycast Target” ,避免其影响下一层的事件接收
- 然后在每个道具的事件处理中都先关闭上一条 Tips ,再打开自己的 Tips
但此时出现了新的问题,比如背包系统中经常会有多种类型的背包,通过 Tab 来切换不同的类型,如果 Tips 无法接收事件,那意味着在这些 Tab 的事件处理中也要加上关闭 Tips 的处理,否则在切换类型时 Tips 无法关闭。
在这样的处理下,原本的逻辑就变得复杂了,如果还有其它组件有 Tab 类似的功能,意味着每一个这样的组件都要加相关的处理,缺少通用性。
使用 EventHandler
参考博客 《 Unity3D 之将 UI 的点击事件渗透下去》 的内容,通过继承 EventHandler 的方法,让当前脚本接收到事件的同时将事件再次传递下去:
using UnityEngine;
using System.Collections;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using System.Collections.Generic;
public class Test : MonoBehaviour, IPointerClickHandler, IPointerDownHandler, IPointerUpHandler
{
// 监听按下
public void OnPointerDown(PointerEventData eventData)
{
PassEvent(eventData, ExecuteEvents.pointerDownHandler);
}
// 监听抬起
public void OnPointerUp(PointerEventData eventData)
{
PassEvent(eventData, ExecuteEvents.pointerUpHandler);
}
// 监听点击
public void OnPointerClick(PointerEventData eventData)
{
PassEvent(eventData, ExecuteEvents.submitHandler);
PassEvent(eventData, ExecuteEvents.pointerClickHandler);
}
// 把事件透下去
public void PassEvent<T>(PointerEventData data, ExecuteEvents.EventFunction<T> function)
where T : IEventSystemHandler
{
List<RaycastResult> results = new List<RaycastResult>();
EventSystem.current.RaycastAll(data, results);
GameObject current = data.pointerCurrentRaycast.gameObject ;
for(int i = 0; i < results.Count; ++i)
{
if(current != results[i].gameObject)
{
ExecuteEvents.Execute(results[i].gameObject, data,function);
// RaycastAll后ugui会自己排序,如果你只想响应透下去的最近的一个响应,
// 这里ExecuteEvents.Execute后直接break就行。
// break;
}
}
}
}
但这样存在的问题是,如果被遮挡的组件除去点击事件还有其它类型的事件要接收(例如拖拽等),意味着要再给其它类型的事件做处理,并且很多事件不仅仅是直接将事件继续传递就可以了的,其中的逻辑没有处理好的话非常容易出现问题,故这套方案的局限性比较大。
利用 EventSystem.RaycastAll
通过分析 UGUI 的源码( UGUI 为开源代码:UGUI Source Code),可以知道点击事件是由 EventSystem 来驱动的。
在 EventSystem 的 Update 中,对当前的 InputMoudle 进行了更新,默认的InputMoudle( PC 上为 StandaloneInputMoudle ,移动端为 TouchInputMoudle ,都继承自 PointerInputModule )在每次更新的时候,会利用 EventSystem.RaycastAll 接口来获取当前点击点能够触发事件的所有 UI 组件,并从中找出排在最前面的一个:
// PointerInputModule.cs
protected PointerEventData GetTouchPointerEventData(Touch input, out bool pressed, out bool released)
{
// 初始化 PointerData
PointerEventData pointerData;
var created = GetPointerData(input.fingerId, out pointerData, true);
pointerData.Reset();
pressed = created || (input.phase == TouchPhase.Began);
released = (input.phase == TouchPhase.Canceled) || (input.phase == TouchPhase.Ended);
if (created)
pointerData.position = input.position;
if (pressed)
pointerData.delta = Vector2.zero;
else
pointerData.delta = input.position - pointerData.position;
pointerData.position = input.position;
pointerData.button = PointerEventData.InputButton.Left;
// 获取所有能接收该 Point 事件的 UI 组件
eventSystem.RaycastAll(pointerData, m_RaycastResultCache);
// 找出排在最前面的 UI 组件
var raycast = FindFirstRaycast(m_RaycastResultCache);
pointerData.pointerCurrentRaycast = raycast;
m_RaycastResultCache.Clear();
return pointerData;
}
获取到最前面的 UI 组件后,即可对它进行事件处理。
判断一个 UI 组件是否能够被触发事件,是由该组件的 Raycast 接口决定的:EventSystem.RaycastAll 时会对所有能够接收事件的 UI 组件调用其 Raycast 接口,如果该接口返回 True ,说明其处在点击范围内,会加入能够接收事件的 UI 队列中。
知道了这一点,面对我们需要解决的需求时,可以采取的思路是:
- 先开启 “Raycast Target” ,要能够接收点击事件
- 如果处在点击范围内,进行记录,但让 Raycast 接口返回 False ,避免隔断其它 UI 组件的事件处理
- 如果记录中本 UI 组件处在点击范围内,则手动进行一次 EventSystem.RaycastAll 检测,并判断自身是否是队首的 UI 组件,如果是,则进行事件处理
通过上面第二点,可以做到事件穿透,通过第三点,可以做到当前 UI 组件的事件处理。实现了需求。核心代码如下:
public override bool Raycast(Vector2 sp, Camera eventCamera)
{
// 记录是否在点击范围内
isRaycasted = base.Raycast(sp, eventCamera);
if (isRaycastTesting)
return isRaycasted;
else
return false;
}
public void Update()
{
// 如果不需要检测,直接返回,避免不必要的计算
if (!isRaycasted)
return;
isRaycasted = false;
// 获取当前点击点的位置
#if UNITY_EDITOR || UNITY_STANDALONE
if (Input.GetMouseButtonDown(0))
{
touchPosition = Input.mousePosition;
#else
if (Input.touchCount > 0 && Input.touches[0].phase == TouchPhase.Began)
{
touchPosition = Input.touches[0].position;
#endif
// 手动调用 EventSystem.RaycastAll
var data = new PointerEventData(EventSystem.current);
data.position = touchPosition;
data.delta = Vector2.zero;
data.button = PointerEventData.InputButton.Left;
var results = new List<RaycastResult>();
isRaycastTesting = true;
EventSystem.current.RaycastAll(data, results);
isRaycastTesting = false;
// 判断是否是队首 UI
bool isFirstObj = false;
for (int i = 0; i < results.Count; ++i)
{
if (results[i].gameObject != null)
{
isFirstObj = (results[i].gameObject == gameObject);
break;
}
}
// 如果是,进行事件处理
if (isFirstObj)
{
// do sth.
}
}
}
优化方向
现存缺陷
虽然以上使用 EventSystem.RaycastAll 手动进行一次检测的方式可以解决目前碰到的问题,但是在性能等方面,其还是存在一些优化点:
- 当前 UI 是否要进行事件处理的判断是在 Update 中做的,虽然不在点击区域之内的时候 Update 会立即返回不进行计算,但是大量的调用还是会存在一定的开销
- 每一个挂载脚本的 UI 组件都有可能触发 EventSystem.RaycastAll ,如果某一个区域重叠了大量的 UI 组件,都触发检测的话,可能对性能有较大的影响
优化思路
- 可以添加一个管理器,所有需要进行判断的逻辑放到管理器进行处理,如果有 UI 需要进行检测,就都加入列表中,由管理器进行统一的 EventSystem.RaycastAll 调用,这样一个是可以减少 Update 的调用,另一个也避免了太多的 EventSystem.RaycastAll
- 另一方面,通过之前的 UGUI 源码分析可以知道,之所以我们要采取手动进行 EventSystem.RaycastAll 操作来实现需求,是因为默认的 InputMoudle 进行该操作之后只保留了队首的 UI ,导致无法对其后的 UI 进行处理,无法实现穿透功能;故其实我们可以通过实现自己的 InputMoudle 来解决这个问题,在自己实现的 InputMoudle 中保存相关的数据,需要的时候就可以直接进行取用,避免了手动调用的开销