EventSystem的使用
EventSystem在Unity中是一个看起来像是专门服务于UGUI系统的组件,每当在场景里创建UGUI对象时,Unity编辑器都会自动产生一个EventSystem对象放在场景中,与之相对应的也有一个Canvas对象,这两个对象就组成了UGUI系统的基础,所有开发人员能看到和能用到的UGUI功能都依附于这两个对象。
UGUI中的EventSystem
使用UGUI制作游戏界面时,EventSystem的作用就像是一个专为UGUI设计好的消息中心,它管理着所有能参与消息处理的UGUI组件,包括但不仅限于Panel,Image,Button等。
如果在Unity创建好EventSystem之后观察该对象上附带的组件可以看到,至少有两个组件会被自动添加,一个是EventSystem组件,也就是消息机制的核心;另一个是StandaloneInputModule,这个是负责产生输入的组件。
StandaloneInputModule本身是个继承自BaseInputModule的实现类,而类似的实现类Unity中还有另外几个,甚至用户也能自定义一个实现类用于事件处理。
看起来这个系统似乎缺少一个部分,就是怎么确定某个事件是发给谁的。在UGUI中,EventSystem主要相应的是用户在界面上的操作,也就是点击(触摸),拖动,长按之类的鼠标动作,那么EventSystem怎么知道这些操作针对的是谁呢?举个简单例子,界面上有好几个Button,用户到底点击了哪个,EventSystem怎么确定这个目标对象的?
对人类来说,看一眼就知道点击的哪个了,可Unity不行,它不能“看”,只能通过数据的运算来感知这些动作,因此为了确定操作对象究竟是哪个,一个必不可少的步骤就是检测。
在GUI之外的游戏场景编辑中,要感知当前鼠标对准的物体是哪个,最常用的方法就是射线检测了,从摄像机对着鼠标指向的方向发出射线,通过碰撞来检测目标。这个方案简单实用,可以说在游戏中随处可见,而UGUI所使用的机制也就是这一套射线检测,只不过射线的发射和碰撞处理都被隐藏在了组件之中。
所以,缺失的部分就是射线检测模块,这个模块不在EventSystem上,而是在Canvas上挂着;这很好理解,Canvas是所有UGUI组件的根对象,所以由他来负责射线处理是相当正常的解决方案,至于射线到底碰到了谁,UGUI组件自然有射线接收反馈来确定。
Canvas上挂载的组件叫做GraphicRaycaster,它实际上是BaseRaycaster的实现类,专门负责Canvas之下的图形对象的射线检测与计算问题。
至此,EventSystem在UGUI中的情况就比较清晰了,一个EventSystem对象负责管理所有事件相关对象,该对象下挂载了EventSystem组件和StandaloneInputModule组件,前者为管理脚本,后者为输入模块。Canvas对象下挂载了GraphicRaycaster负责处理射线相关运算,用户的操作都会通过射线检测来映射到UGUI组件上,InputModule将用户的操作转化为射线检测,Raycaster则找到目标对象并通知EventSystem,最后EventSystem发送事件让目标对象进行响应。
事件响应
UGUI的事件响应处理有多种方式,最简单而直接的一种应该就是通过实现特定接口的方法来处理事件响应了。
由于Canvas挂载了GraphicRaycaster组件,因此在Canvas对象之下的所有GUI对象都可以通过挂载脚本并且实现一些和事件相关的接口来处理事件,比如常见的IPointerClickHandler接口就是用于处理点击事件的接口。
可以实现的接口列表大概如下所示
- IPointerEnterHandler - OnPointerEnter - Called when a pointer enters the object
- IPointerExitHandler - OnPointerExit - Called when a pointer exits the object
- IPointerDownHandler - OnPointerDown - Called when a pointer is pressed on the object
- IPointerUpHandler - OnPointerUp - Called when a pointer is released (called on the original the pressed object)
- IPointerClickHandler - OnPointerClick - Called when a pointer is pressed and released on the same object
- IInitializePotentialDragHandler - OnInitializePotentialDrag - Called when a drag target is found, can be used to initialise values
- IBeginDragHandler - OnBeginDrag - Called on the drag object when dragging is about to begin
- IDragHandler - OnDrag - Called on the drag object when a drag is happening
- IEndDragHandler - OnEndDrag - Called on the drag object when a drag finishes
- IDropHandler - OnDrop - Called on the object where a drag finishes
- IScrollHandler - OnScroll - Called when a mouse wheel scrolls
- IUpdateSelectedHandler - OnUpdateSelected - Called on the selected object each tick
- ISelectHandler - OnSelect - Called when the object becomes the selected object
- IDeselectHandler - OnDeselect - Called on the selected object becomes deselected
- IMoveHandler - OnMove - Called when a move event occurs (left, right, up, down, ect)
- ISubmitHandler - OnSubmit - Called when the submit button is pressed
- ICancelHandler - OnCancel - Called when the cancel button is pressed
只要在挂载的脚本中实现所需要的接口,对应的事件回调也就可以执行了。
public class EventTest : MonoBehaviour, IPointerClickHandler, IDragHandler, IPointerDownHandler, IPointerUpHandler {
public void OnDrag(PointerEventData eventData) {
// Execute every update when dragging
}
public void OnPointerClick(PointerEventData eventData) {
// quick down and up will perform click
}
public void OnPointerDown(PointerEventData eventData) {
// pointer down
}
public void OnPointerUp(PointerEventData eventData) {
// pointer up
}
// Use this for initialization
void Start () {
//
}
// Update is called once per frame
void Update () {
//
}
}
第二种方式相对要复杂一些,主要是利用了EventTrigger组件。
EventTrigger组件是一个通用的事件触发器,它可以用来管理单个组件上的所有可能触发的事件,其使用方法有编辑器设定和动态设置两种。
编辑器设定方法是在指定组件上添加EventTrigger组件,然后为它添加触发事件类型,再为指定类型添加回调方法。这种做法的操作很简单,而且灵活性也相当高,想要跨脚本调用方法只需要鼠标拖一拖点一点就好。
但是这样在编辑器中设定事件回调会在项目变大时造成比较严重的管理障碍,尤其是当绑定了EventTrigger以及回调指向的物体有修改或者删除情况时,所造成的引用缺失需要花费更多的时间进行处理。
所以,想要更好地管理大量的事件触发和回调处理,可以尝试采用动态设置的方案。
所谓动态设置其实就是在代码中设置EventTrigger来处理事件回调,方法也很简单
protected void setupEventTrigger(GameObject target, UnityAction<BaseEventData> listener, EventTriggerType type) {
if(target != null) {
EventTrigger trigger = target.GetComponent<EventTrigger>() as EventTrigger;
if(trigger == null) {
trigger = target.AddComponent<EventTrigger>();
}
trigger.triggers = new List<EventTrigger.Entry>();
EventTrigger.Entry entry = new EventTrigger.Entry();
entry.eventID = type;
entry.callback = new EventTrigger.TriggerEvent();
entry.callback.AddListener(listener);
trigger.triggers.Add(entry);
}
}
将这个方法放到需要的类中,然后针对某个GameObject调用即可。
当然还可以把这个方法变为拓展方法,更方便调用。
public static void setupEventTrigger(this GameObject obj, UnityAction<BaseEventData> listener, EventTriggerType type) {
EventTrigger trigger = obj.GetComponent<EventTrigger>() as EventTrigger;
if (trigger == null) {
trigger = obj.AddComponent<EventTrigger>();
}
trigger.triggers = new List<EventTrigger.Entry>();
EventTrigger.Entry entry = new EventTrigger.Entry();
entry.eventID = type;
entry.callback = new EventTrigger.TriggerEvent();
entry.callback.AddListener(listener);
trigger.triggers.Add(entry);
}
参数中的UnityAction类型是Unity自带的一个事件委托封装,其用法和C#的Action类很相似,只要将一个参数为BaseEventData的方法作为参数传入即可。而EventTriggerType是一个枚举类,其中包含了所有可能用到的事件类型,在这里用于说明当前绑定的回调是响应哪个事件的。
需要注意的是,EventTrigger绑定的回调会消化掉事件,因此如果还有事件透传的需求要手动解决;比如在ScrollView中的组件,如果使用添加EventTrigger的方法来实现了点击事件回调则ScrollView的滑动事件就被消化掉了,换言之此时如果点击到组件上并拖动,ScrollView并不会滑动,因为事件已经被实现了点击事件回调的组件拦截下来了。
除了使用透传,针对点击事件和拖拽事件的冲突问题还有另外一种解决方案,那就是Button组件。
UGUI的Button组件上挂载了一个名为Button的脚本,它的主要作用就是实现和Button相关的一系列功能,包括回调,鼠标悬浮,点击变色等等。而这个脚本里有一个预设的点击事件onClick,这个字段本身是个ButtonClickedEvent对象,而这个类继承自UnityEvent,它的实现里有关于回调的两个方法AddListener和RemoveListener,用起来就像是传统的监听器那样。
如果不使用EventTrigger来设置PointerClick事件回调,而是为组件添加Button脚本并通过向onClick增加监听器来实现点击事件回调的话,那么事件的冲突问题就得到了解决,Button脚本只会响应并消化跟点击相关的事件,其它的事件都会自动透传。
Button的使用方法如下
protected void setupClickListener(Button btn, UnityAction listener, bool isAppend = false) {
if(btn != null) {
if(!isAppend) {
btn.onClick.RemoveAllListeners();
}
btn.onClick.AddListener(listener);
}
}
注意在调用方法前要通过GameObject.AddComponent方法来为指定对象添加Button组件,使用返回值作为参数调用设置方法。
当然也可以将它改为拓展方法
public static void setupOnclickListener(this GameObject obj, UnityAction listener, bool isAppend = false) {
Button btn = obj.GetComponent<Button>();
if(btn == null) {
btn = obj.AddComponent<Button>();
}
if(!isAppend) {
btn.onClick.RemoveAllListeners();
}
btn.onClick.AddListener(listener);
}
这样一来使用就更方便了。
利用Button来处理点击事件有一些取巧的意思,因此在大部分情况下还是建议使用标准的方案,实现接口或者使用EventTrigger都可以,透传问题手动解决要比这样取巧更便于解决可能出现的问题,泛用性也更大。
场景中的EventSystem
EventSystem虽然多见于UGUI使用中,但其实它也能在一般的场景中使用,如果没有实现自己的事件系统而又需要一些回调处理的方案的话,可以试着直接将EventSystem应用到一般的游戏场景中。
要这样使用EventSystem的话,核心在于前文提到过的事件系统三大部分,分别是EventSystem,InputModule和Raycaster。通过考察三者各自的作用可知,EventSystem和InputModule都和EventSystem对象紧密结合,而唯有Raycaster是孤零零地在Canvas对象上处理所有Canvas内部的射线检测。
那么想要借助EventSystem的能力来处理场景中的事件传递,肯定不能去动EventSystem对象,毕竟这是建立事件系统时自动创建的对象,不用说一定是要用到的。那么就只剩下Raycaster了,这个组件在Canvas上挂载,用于处理射线检测,那么如果想要在场景里进行射线检测,应该把组件挂到哪里呢?
一般而言,摄像机是一个不错的选择,因为通常来说游戏大部分时候都只有一个摄像机,而且基本上可以操作的界面也只隶属于一个摄像机,因此将Raycaster挂载到游戏的主摄像机上就是个很自然的考虑了。
而Unity编辑器提供的Raycaster一共有三种
- GraphicRaycaster 界面射线处理器,用于Canvas
- Physics2DRaycaster 2D场景射线处理器,用于2D场景
- PhysicsRaycaster 3D场景射线处理器,用于3D场景
因此要用到的就是后两种了,根据当前场景的特点选择相应的Raycaster并挂载到主摄像机上即可,剩下的就和UGUI中很像了。不过需要注意的是,在UGUI中想要让组件可以响应事件必须将组件的RaycasterTarget属性勾选上,而场景中则要在需要响应事件的对象上挂载碰撞器,满足需求的任何碰撞器都可以。
然后就和前文讲的一样,实现对应接口或者添加EventTrigger组件来实现各种事件回调。
一个需要重视的地方在于,使用这样的方案实现的回调,其传递的数据PointerEventData中包含的位置参数还是屏幕位置,而且跟像素相关,以屏幕左下角为原点的坐标。如果希望获取触发事件时的世界坐标,则需要用到PointerEventData类中的pointerCurrentRaycast成员,该成员表示了射线检测的结果,因此其中包含碰撞点的世界坐标。
更多关于EventSystem的信息可以参考Unity3D官方手册或者博文