UGUI是基于事件的GUI系统,无论是内置的UI组件,还是我们自定义的一些交互,都需要借助UGUI的事件系统来表达。对于任何GUI框架来说,事件系统都是相对比较复杂的,不过实际上它们都有着类似的设计。这篇笔记我们详细介绍,如何在Unity编辑器使用UGUI的事件系统,以及如何使用纯C#代码动态指定事件的回调函数。
我们在场景中放入一个UI组件后,会发现Unity编辑器自动为我们创建了一个EventSystem
对象,它相当于UI系统的一个事件管理器。该对象组件上有一些配置项,不过我们一般都不需要修改。
在组件中,有一个Event分类,其中包含了和UGUI事件系统相关的组件。
EventSystem
对象上Canvas
上Canvas
上Canvas
上EventSystem
对象上EventSystem
对象上我们编写UI时,涉及最多的内容就是绑定监听器触发事件,其中最常用的其实就是Event Trigger
。下面例子中,我们使用Event Trigger
组件,为一个Image
控件绑定点击事件的回调(Image
默认不响应任何事件)。
如图所示,我们在Image
上面,增加了一个Event Trigger
组件,然后为其配置了一个回调函数。
public void HandleClick()
{
Debug.Log("target clicked");
}
注意:回调函数必须为public
,至多有一个参数,不能有返回值。
实际开发中,其实更为常见的一种状况是:我们的对象是动态生成的,那么此时如何为其指定事件的回调函数呢?其实Unity编辑器中属性面板的各种操作都完全可以一比一改为C#代码,和编辑器中类似,我们使用代码为对象添加Event Trigger
组件,然后绑定函数即可。
// 实例化游戏对象
GameObject skillItem = Instantiate(skill.IconPrefab);
skillItem.transform.SetParent(transform, false);
// 添加Event Trigger组件
EventTrigger eventTrigger = skillItem.AddComponent<EventTrigger>();
// 添加开始拖拽事件
EventTrigger.Entry entry1 = new EventTrigger.Entry();
entry1.eventID = EventTriggerType.BeginDrag;
entry1.callback.AddListener((BaseEventData baseEventData) =>
{
HandleSkillItemBeginDrag(skill);
});
eventTrigger.triggers.Add(entry1);
上面代码节选自之后我们要介绍的例子。代码中,我们实例化了一个名为skillItem
的游戏对象,然后为其添加了Event Trigger
组件,最后为其绑定了一个响应BeginDrag
事件的回调函数。
其实之前编辑器中我们手动点击Add New Event Type
按钮,其作用就是给Event Trigger
组件添加EventTrigger.Entry
对象,该对象的eventID
字段对应事件的类型,callback
字段则是一个UnityEvent<BaseEventData>
类型,其AddListener()
方法用于注册UnityAction<BaseEventData>
,后者本质上是一个委托(delegate),我们可以直接传入函数或是传入一个lambda表达式。
Unity中,内置了如下所示的事件类型:
前面例子中,我们使用的PointerUp
,会在鼠标抬起时触发。其他触发类型也都比较好理解,参考文档即可,这里就不多介绍了。
学习了UGUI事件系统相关知识后,在使用UGUI内置的Button
按钮等控件时,其实就轻车熟路了。例如Button等,其事件本质上也是基于Event Trigger实现的,我们可以通过Unity编辑器进行编辑,也可以通过代码进行动态的指定。
有关UGUI内置组件,将在后续章节详细介绍。
这里我们编写一个将技能图标拖拽进技能栏的例子,技能数据从XML文件中加载,我们可以从上方的技能列表中拖动任意技能到下方的技能槽,点击可以实现技能触发和冷却中的特效,最终效果如图:
注:技能图来自网络
创建的UI对象如图所示:
其中,SkillPanel
是展示技能列表的容器,我们会读取一个XML文件,然后将所有的技能以GridLayoutGroup
的布局加载到SkillPanel
中。SkillSlotPanel
是我们的技能槽面板,其中包含了3个技能槽SkillSlot0
、SkillSlot1
、SkillSlot2
,每一个技能槽包含两个子对象,SlotImage
用于显示技能的图标,SlotMask
是一个遮罩,我们这里使用Filled
模式的Image对象实现遮罩的效果,间接实现技能冷却的效果。
我们总共创建了3个C#脚本:
Skill.cs
SkillPanelController.cs
SkillSlotController.cs
Skill.cs
是数据模型,其中包含了技能相关的字段,和XML文件对应。SkillPanelController
是技能列表的控制器(这里使用了MVC模式),它用于加载技能列表,以及控制渲染技能数据。SkillSlotController
附加到了每一个技能槽中,它用于响应当前技能槽的拖拽Drop事件,以及控制技能释放和冷却。
这里首先要说明的是如何实现技能冷却的效果,虽然这个问题和本章节的主题不太相关。其实技能冷却效果是两个重叠的Image
控件实现的,上面一层其颜色被设置为了叠加灰色,因此比原图暗。UGUI中的Image
控件有一个Image Type
属性,其取值:简单模式Simple
和九宫格模式Sliced
不必多说,大家应该都能理解,比较神奇的是Filled
模式,它能实现按一定角度或是某个方向被遮蔽,间接实现上面遮罩的效果。
Skills.xml
<skills>
<skill>
<id>1</id>
<name>电刃</name>
<iconPrefab>Skills_0</iconPrefab>
</skill>
<skill>
<id>2</id>
<name>冲击</name>
<iconPrefab>Skills_1</iconPrefab>
</skill>
<skill>
<id>3</id>
<name>血刃</name>
<iconPrefab>Skills_2</iconPrefab>
</skill>
<skill>
<id>4</id>
<name>风刃</name>
<iconPrefab>Skills_3</iconPrefab>
</skill>
<skill>
<id>5</id>
<name>地爆天星</name>
<iconPrefab>Skills_4</iconPrefab>
</skill>
</skills>
上面代码是我们作为数据的XML文件,其中包括了我们技能的ID、名字和图标预制体名,预制体包含了技能的Sprite
,用于我们动态加载和显示技能图标。当然这里我们仅仅是演示UGUI用法的Demo,设置的字段都非常简单,实际开发中「技能」可是个相当复杂的系统。
Skill.cs
using UnityEngine;
public class Skill
{
public int Id { get; set; }
public string Name { get; set; }
public GameObject IconPrefab { get; set; }
}
上面代码是技能的数据模型,字段和XML中对应。
SkillPanelController.cs
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Xml;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
public class SkillPanelController : MonoBehaviour
{
private static SkillPanelController instance;
private List<Skill> skillList;
public GameObject CurrentFollower { get; private set; }
public Skill CurrentFollowerSkill { get; private set; }
public static SkillPanelController GetInstance() { return instance; }
private void Awake()
{
instance = this;
skillList = new List<Skill>();
// 读取技能XML定义文件
TextAsset textAsset = Resources.Load<TextAsset>("Skills");
string skillDefs = textAsset.text;
// 解析XML文件生成技能对象
XmlDocument doc = new XmlDocument();
doc.LoadXml(skillDefs);
XmlElement root = (XmlElement)doc.SelectSingleNode("skills");
XmlNodeList defs = root.ChildNodes;
foreach (XmlNode node in defs)
{
int id = int.Parse(node.SelectSingleNode("id").InnerText);
string name = node.SelectSingleNode("name").InnerText;
string iconPrefabName = node.SelectSingleNode("iconPrefab").InnerText;
GameObject iconPrefab = Resources.Load<GameObject>(iconPrefabName);
Skill skill = new Skill() { Id = id, Name = name, IconPrefab = iconPrefab };
skillList.Add(skill);
}
// 填充技能列表UI元素
foreach (Skill skill in skillList)
{
// 构造技能可拖拽元素
GameObject skillItem = Instantiate(skill.IconPrefab);
skillItem.transform.SetParent(transform, false);
EventTrigger eventTrigger = skillItem.AddComponent<EventTrigger>();
// 添加开始拖拽事件
EventTrigger.Entry entry1 = new EventTrigger.Entry();
entry1.eventID = EventTriggerType.BeginDrag;
entry1.callback.AddListener((BaseEventData baseEventData) =>
{
HandleSkillItemBeginDrag(skill);
});
eventTrigger.triggers.Add(entry1);
// 添加拖拽中事件
EventTrigger.Entry entry2 = new EventTrigger.Entry();
entry2.eventID = EventTriggerType.Drag;
entry2.callback.AddListener((BaseEventData baseEventData) =>
{
HandleSkillItemDrag();
});
eventTrigger.triggers.Add(entry2);
// 添加拖拽结束事件
EventTrigger.Entry entry3 = new EventTrigger.Entry();
entry3.eventID = EventTriggerType.EndDrag;
entry3.callback.AddListener((BaseEventData baseEventData) =>
{
HandleSkillItemEndDrag();
});
eventTrigger.triggers.Add(entry3);
}
}
public void HandleSkillItemBeginDrag(Skill skill)
{
CurrentFollowerSkill = skill;
CurrentFollower = Instantiate(skill.IconPrefab);
CurrentFollower.transform.position = Input.mousePosition;
CurrentFollower.GetComponent<Image>().raycastTarget = false;
CurrentFollower.transform.SetParent(GameObject.Find("Canvas").transform);
}
public void HandleSkillItemDrag()
{
CurrentFollower.transform.position = Input.mousePosition;
}
public void HandleSkillItemEndDrag()
{
Destroy(CurrentFollower);
CurrentFollower = null;
CurrentFollowerSkill = null;
}
}
上面代码主要包含两个部分,Awake()
方法中包含了读取XML文件,加载技能列表GUI的逻辑,其中比较复杂就是动态拼装技能列表中的每一个技能,我们动态的实例化了技能图标对象,并为其绑定了BeginDrag、Drag、EndDrag这3个拖拽事件;后面的拖拽事件回调函数就比较简单了,拖拽开始我们再实例化一个技能图标,并在拖拽中设置其实时跟随鼠标,拖拽结束即将其销毁。
注意:代码中的一个小技巧是设置了CurrentFollower.GetComponent<Image>().raycastTarget = false;
,这是因为我们拖拽时可能发生跟随鼠标的图标把Drop
事件挡住的问题,此时后面的技能槽就无法收到事件了,因此我们设置raycastTarget
属性为false
,事件就会穿过跟随鼠标的图标。
SkillSlotController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class SkillSlotController : MonoBehaviour
{
private Image slotImage;
private Image maskImage;
private Skill currentSkill;
private bool showingMask = false;
private float showingTime = 0;
private float maxShowingTime = 1;
private void Awake()
{
slotImage = transform.Find("SlotImage").GetComponent<Image>();
maskImage = transform.Find("SlotMask").GetComponent<Image>();
currentSkill = null;
}
public void HandleDrop()
{
// 将拖拽中的技能设置到当前技能槽的2个图片上
SkillPanelController skillPanelController = SkillPanelController.GetInstance();
slotImage.sprite = skillPanelController.CurrentFollower.GetComponent<Image>().sprite;
maskImage.sprite = skillPanelController.CurrentFollower.GetComponent<Image>().sprite;
// 设置当前技能槽技能
currentSkill = skillPanelController.CurrentFollowerSkill;
}
public void HandleClick()
{
// 当前技能槽不为空且不在冷却中,允许发动技能
if (currentSkill != null && !showingMask)
{
// 显示冷却遮罩
maskImage.gameObject.SetActive(true);
maskImage.type = Image.Type.Filled;
showingMask = true;
showingTime = 0;
}
}
private void Update()
{
// 在1s之内更新遮罩的遮蔽角度,实现技能冷却效果
if (showingMask)
{
showingTime += Time.deltaTime;
}
maskImage.fillAmount = (1 - showingTime / maxShowingTime);
if (showingTime > maxShowingTime)
{
showingMask = false;
maskImage.gameObject.SetActive(false);
}
}
}
技能槽脚本响应了Drop
事件,它是拖拽目标响应的事件,我们这里收到拖拽事件后,就将当前技能槽的图标、技能数据都设置为被拖拽的技能,此时技能拖拽功能就实现了。HandleClick
响应技能槽的点击事件,前面介绍过技能冷却效果的实现原理,这里就是其实现代码。
对于技能槽,由于其是相对静态的对象,我们这里使用的是通过Unity编辑器指定回调事件的方式。如下图所示: