Unity3D提供的UGUI系统非常适合于制作基本的用户图形操作界面,也就是常说的GUI。在标准的UGUI组件包里有很多常用的UI组件,只需要将它们进行组合便能制作出合乎要求又好用的图形界面。
但是UGUI并没有提供一个常用的界面组件,即列表展示组件,取而代之的是一个更加泛化的滑动组件ScrollView,这个组件的特点就是能轻松地做出水平或垂直方向有滑动能力的界面组件,甚至任意方向滑动也不难做到,可以使用它来制作诸如垂直或者水平的列表以及可以随意滑动的地图等。
固然使用ScrollView实现列表展示毫无压力,灵活性也更高,但针对特定的项目需求,使用封装好的列表组件无疑是能大大增加开发效率的。
要封装属于自己的列表组件,直接从ScrollView入手是很不错的开始,那么如何利用ScrollView来封装一个列表呢?
首先要看ScrollView的结构,在Unity中新增一个标准ScrollView组件到Canvas上就能看到,ScrollView的结构如下所示
结构中的Viewport/Content便是最主要的放置列表项的地方,两个Scrollbar毫无疑问是滚动条,一个控制水平一个控制垂直,Sliding Area限制了滚动条的区域,Handle则是滚动条中用来表示当前位置的滑块。
那么要制作一个列表,首先就是确定列表是水平的还是垂直的,如果不确定这一点,列表会退化为一个可以随意滑动的区域,失去了设计列表的本意。
一般而言列表是垂直的,那么就按照垂直列表的情况来,如果有水平列表需求,其设计过程也是类似的。
因此先调整ScrollView属性页上的ScrollRect组件
将Horizontal后面的勾去掉,则该ScrollView会被限制只能在垂直方向上滑动,这样一来水平的滚动条也就失去了作用,将它删掉并调整垂直滚动条的大小让它适应整个ScrollView即可。
调整完成后如图所示
现在有了一个仅能垂直滑动的组件,下一步就是将它变成一个列表了。而UGUI并没有原生列表的概念,因此要想实现列表,只能模拟。
所谓模拟列表就是指将列表显示的子项放入Content中让它显示出来,因为Viewport上挂载了Mask组件,所以当Content的内容范围很大,超出了Viewport的大小后就会被裁剪,而ScrollView挂载的ScrollRect组件则保证了其Content子元素可以滑动,这样便模拟出了列表的效果。
从模拟的过程可以看出,其实这个列表是把所有要显示的东西放到界面上,然后用Mask裁剪,展现给用户的就是裁剪过后的样子;再通过ScrollRect滑动起来,就像是滑动出了下面的子项一样。
知道了这个流程,那么方案也随之出炉了,既然模拟这个列表的重点在Content,那么只需要给ScrollView挂上脚本,用脚本控制Content中的子项即可。
列表子项控制脚本
public class ListItem : MonoBehaviour {
private Text contentText;
private string contentStr;
private bool needUpdate = false;
void Start() {
contentText = gameObject.GetComponent();
}
void Update() {
if(needUpdate) {
contentText.text = contentStr;
needUpdate = false;
}
}
public void setContent(string data) {
contentStr = data;
needUpdate = true;
}
}
以上脚本应挂载到列表的子项预制体上,方便控制每一项的显示
列表控制脚本
public class ScrollList : MonoBehaviour {
public GameObject itemPrefab;
private Transform content;
private List<string> dataList;
private bool needUpdate = false;
void Start() {
content = transform.Find("Viewport/Content");
}
void Update() {
if(needUpdate) {
if(dataList != null) {
for(int i = 0; i < dataList.Count; i++) {
if(i < content.childCount) {
content.getChild(i).gameObject.GetComponent().setData(dataList[i]);
} else {
GameObject obj = GameObject.Instantiate(itemPrefab);
obj.GetComponent().setData(dataList[i]);
}
}
if(dataList.Count < content.childCount) {
for(int i = dataList.Count; i < content.childCount; i++) {
Destory(content.getChild(i).gameObject);
}
}
}
needUpdate = false;
}
}
public void setData(List<string> data) {
dataList = data;
needUpdate = true;
}
}
将以上脚本挂载到之前修改好的ScrollView组件上,列表就可以使用了。
实际使用中只需要获取到列表对象上挂载的ScrollList脚本对象,并使用它来设置好数据列表,组件会自动生成足够的预制体来显示内容,多余的则会被摧毁掉。
但这个组件现在依然是有问题的,最大的一个问题就是它现在还无法正常滑动,而且子项的显示也有问题,运行后回到编辑器界面可以看到,被创建的子项会处于错误的位置导致无法显示,因此还要对组件本身进行一番修改。
首先是为Content对象挂载一个VerticalLayoutGroup组件,然后将其ChildForceExpand属性的两个值Width和Height都勾上。
Padding和Spacing都可以根据实际需求调整
此时仍然无法达到要求,因为Content的实际高度不会因为子项的增多而变大,而且VerticalLayoutGroup也需要根据高度来分配每个子项的占位。
因此再给Content对象挂载一个ContentSizeFitter组件
注意此时Horizontal Fit应选择Unconstrained表示宽度不用限制,Content对象本身是自适应宽度的,只要子项强制展开宽度即可;而Vertical Fit设置为Preferred Size,表示高度要根据子项的情况自动适应。
随后给子项的预制体挂载一个LayoutElement组件,勾上Preferred Height并设置好所需的高度。
这样一来Content Size Fitter便可以正常地自动限制Content对象的高度了。经过如上的一些改进,ScrollList脚本可以正常工作,一个简单的列表组件就设计完成了。
当然了,改进的空间依然存在,比如在重复使用列表时的销毁并新建对象的开销,大量对象新建的卡顿等。
在之前编写的ScrollList组件里,子项itemPrefab是通过预制体给出的,但数据却是固定为List的形式,如果想要改变,要么在ScrollList脚本里添加对应的数据项和设置方法并修改Update代码;要么就照着ScrollList再写一个脚本专门用于新的数据形式。无论是哪种方案,事实上都透露出这个简单解决方案的不足,那就是耦合性极高,很难适应多样的需求。
如何解决这个问题?或许一时半会儿没有思路,那么可以尝试从别的地方找找灵感。针对列表这个组件,手机应用中的列表是很强大的,以安卓为例,它的ListView使用方便,渲染速度快,正确使用重用机制后性能很好,非常适合的一个参考例子。
那么安卓系统的ListView具体是怎么设计的呢?也不需要了解得太详细,讲解ListView的文章很多,只是寻找思路无需阅读源代码。
简单说来,安卓的ListView具有几个重要的特点
以上三点中的前两点可以为UGUI的列表设计提供很好的思路。因此接下来就参考安卓的ListView设计一个更抽象和泛用的列表组件。
首先设计一个接口,用于Adapter
抽象适配器接口
public interface IAdapter {
int getCount(); // 获取列表项数目
int getItemId(int index); // 获取项目ID
object getItem(int index); // 获取项目数据
GameObject getObject(GameObject prev, int index); // 生成项目对象
}
其中包含的几个方法都是用于为列表生成子项的。接着设计列表使用的更新接口,这个接口的设计是为了进一步抽象列表的刷新功能.
对象刷新接口
public interface IUpdateViews {
void updateViews(); // 刷新界面对象
}
然后编写Adapter的抽象基类,代码所写的并非唯一方案,但适用于大部分情况。
适配器抽象基类
public abstract class BaseAdapter:IAdapter {
protected IUpdateViews updateCall; // 列表刷新接口
public abstract int getCount(); // 返回列表项目数
public abstract int getItemId(int index); // 根据索引返回项目ID
public abstract object getItem(int index); // 根据索引返回数据项,需要转换
public abstract GameObject getObject(GameObject prev, int index); // 根据当前列表项以及索引更新项目或构造新项
public void setupUpdateCall(IUpdateViews call) { // 设置刷新回调接口
updateCall = call;
}
public void notifyDataChanged() { // 通知列表更新
if(updateCall != null) {
updateCall.updateViews();
}
}
}
可以看到在BaseAdapter中使用了IUpdateView作为更新接口,方便不同类型的列表使用同样一套Adapter机制。
然后设计一个新的列表组件脚本,原理和前文提到的那种简单实现类似,但这一次要使用Adapter机制实现列表项的生成和刷新。
通用列表对象类
public class CommonListView : MonoBehaviour, IUpdateViews {
private const string COMMON_LIST_CONTENT_PATH = "Viewport/Content"; // 通用列表项目集合的搜索路径
protected BaseAdapter baseAdapter; // 列表适配器
protected Transform contentRoot; // 内容根对象
protected int overIndex; // 全局索引
protected int prevCount; // 缓存上一次的项目数量
private bool updateFlag = false; // 更新标志
void Awake() {
preLoad();
}
void Start() {
loadViews();
}
void Update() {
execute();
}
protected void preLoad() {
overIndex = 0;
prevCount = 0;
}
protected void loadViews() {
contentRoot = transform.Find(COMMON_LIST_CONTENT_PATH);
VerticalLayoutGroup grp = contentRoot.gameObject.AddComponent();
grp.childForceExpandWidth = true;
grp.childControlHeight = true;
grp.childControlWidth = true;
ContentSizeFitter fitter = contentRoot.gameObject.AddComponent();
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
}
// 设置更新标志,重置索引,启动更新过程
public void updateViews() {
overIndex = 0;
notifyUpdate();
}
// 每帧执行,根据更新标志进行更新
protected void execute() {
if (baseAdapter != null && updateFlag) {
if (baseAdapter.getCount() > 0) {
int innerCount = baseAdapter.getCount() / 60 + (baseAdapter.getCount() % 60 == 0 ? 1 : 0); // 计算每帧需要更新的项目数
innerCount += (innerCount == 0 ? 1 : 0);
for (int i = 0; i < innerCount; i++) { // 内循环
GameObject obj = null;
bool needInsert = true;
if (overIndex < contentRoot.childCount) {
obj = contentRoot.GetChild(i).gameObject;
needInsert = false;
}
obj = baseAdapter.getObject(obj, overIndex);
if (needInsert) {
obj.transform.SetParent(contentRoot);
obj.transform.localPosition = Vector3.zero;
obj.transform.localScale = Vector3.one;
}
overIndex++;
if (overIndex >= baseAdapter.getCount()) { // 全部项目更新完成
updateFlag = false;
int lastIndex = contentRoot.childCount - 1;
while (overIndex <= lastIndex) { // 项目少于已有的项目
obj = contentRoot.GetChild(lastIndex).gameObject;
Destroy(obj); // 将不需要的项目销毁
lastIndex--;
}
break;
}
}
} else { // 列表为空时销毁所有项目
for (int i = 0; i < contentRoot.childCount; i++) {
GameObject obj = contentRoot.GetChild(0).gameObject;
Destroy(obj);
}
updateFlag = false;
}
}
}
public void notifyUpdate() { // 通知列表刷新
if (!updateFlag) {
updateFlag = true;
}
}
public void setAdapter(BaseAdapter adapter) { // 设置适配器
baseAdapter = adapter;
baseAdapter.setupUpdateCall(this); // 装载列表刷新接口,观察者模式成立
notifyUpdate();
}
public BaseAdapter getAdapter() {
return baseAdapter;
}
}
在setAdapter方法中为Adapter引入了刷新接口,观察者模式成立,此后数据的更新不再需要操作列表组件,Adapter可以代劳。
在loadViews方法中主动添加了VerticalLayoutGroup组件和ContentSizeFitter组件,这样一来就不必在编辑器里手动为Content对象添加这些组件了。
在execute方法中是主要的列表项更新代码,在这里使用了多帧更新的方法,因为在当前列表组件的脚本代码中,当首次载入时会有大量的GameObject被创建,列表长度变化时也会涉及到GameObject的创建与销毁,因此如果将所有操作集中到一帧循环之内完成则造成的卡顿会非常明显。
因此将所有列表项按照60帧的最大限制进行分段,每段不足1个项目时补为1,这样一来列表项的创建和刷新会被分散到多个帧里完成,实测表明即便是200个以上的列表项创建,其实际的卡顿情况也不严重,并且在加载完成后就不再卡顿了。
这样一来,新列表就可以工作了,具体使用方法如下。
首先准备好ScrollView并挂载新编写的脚本,准备好项目的预制体。
随后根据数据格式编写Adapter,继承BaseAdapter并实现各个必要方法。
自定义适配器
public class ListAdapter : BaseAdapter {
private List<string> dataList; // 数据列表
public List<string> DataList { // 属性,隐藏掉get
set {
dataList = value;
}
}
public GameObject itemPrefab; // 子项预制体,直接设置即可
public override int getCount() {
return dataList == null ? 0 : dataList.Count;
}
public override object getItem(int index) {
return dataList == null ? null : dataList[index];
}
public override int getItemId(int index) {
return index;
}
public override GameObject getObject(GameObject prev, int index) {
try {
if(prev == null) {
prev = GameObject.Instantiate(itemPrefab);
}
ListItem item = prev.GetComponent();
string data = (string)getItem(index);
item.setContent(data);
} catch(Exception e) {
// 发生异常
}
return prev;
}
}
然后在主场景的脚本中获取到新列表的脚本对象,创建Adapter并设置到列表,修改数据并刷新就能看到效果了。
主场景脚本
public class TestScript : MonoBehaviour {
public GameObject listItem;
private CommonListView scrollList;
private bool updateFlag = true;
List<string> dataList = new List<string>();
private ListAdapter listAdapter;
// Use this for initialization
void Start () {
scrollList = transform.Find("ScrollView").gameObject.GetComponent();
for(int i = 0; i < 40; i++) {
dataList.Add("The Item - " + i);
}
listAdapter = new ListAdapter();
listAdapter.DataList = dataList;
listAdapter.itemPrefab = listItem;
scrollList.setAdapter(listAdapter);
}
// Update is called once per frame
void Update () {
if(updateFlag) {
listAdapter.notifyDataChanged();
updateFlag = false;
}
}
}
现在列表组件虽然已经可以使用了,但依然存在可以优化的细节。
比如可以为组件脚本添加公有字段来方便用户设定间距和四向Padding,还有增加一个标志来让列表可以在垂直与水平两个状态间切换
public bool isHorizontal = false; // 设置是否水平布局(默认垂直布局,即普通列表)
public RectOffset paddingOffset; // 设置项目边距
public int spacing; // 设置项目间距
有了字段后修改一下loadViews方法
protected void loadViews() {
contentRoot = transform.Find(COMMON_LIST_CONTENT_PATH);
if(isHorizontal) {
HorizontalLayoutGroup grp = contentRoot.gameObject.AddComponent();
grp.padding = paddingOffset;
grp.spacing = spacing;
grp.childForceExpandHeight = true;
grp.childControlHeight = true;
grp.childControlWidth = true;
ContentSizeFitter fitter = contentRoot.gameObject.AddComponent();
fitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
} else {
VerticalLayoutGroup grp = contentRoot.gameObject.AddComponent();
grp.padding = paddingOffset;
grp.spacing = spacing;
grp.childForceExpandWidth = true;
grp.childControlHeight = true;
grp.childControlWidth = true;
ContentSizeFitter fitter = contentRoot.gameObject.AddComponent();
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
}
}
还可以增加一个缓存字典,将已经创建好的列表项缓存下来,不需要的项目设置为非活动状态即可
protected Dictionary<int,GameObject> objectCache; // 列表项目缓存,改善效率
然后修改execute方法的代码,实现缓存机制
protected override void execute() {
if(baseAdapter != null && updateFlag) {
if (baseAdapter.getCount() > 0) {
int innerCount = baseAdapter.getCount() / 60 + (baseAdapter.getCount() % 60 == 0 ? 1 : 0); // 计算每帧需要更新的项目数
innerCount += (innerCount == 0 ? 1 : 0);
for (int i = 0; i < innerCount; i++) { // 内循环
GameObject obj = objectCache.TryGetElement(baseAdapter.getItemId(overIndex)); // 查询缓存
bool hasItem = false;
if (obj != null) { // 若存在则无需新建
hasItem = true;
obj.SetActive(true);
LayoutElement e = obj.GetComponent() as LayoutElement;
if (e != null) {
e.ignoreLayout = false;
}
}
obj = baseAdapter.getObject(obj, overIndex); // getObject方法将会判断是否需要新建对象
if (!hasItem) { // 新对象放到contentRoot的子对象中
obj.transform.SetParent(contentRoot);
obj.transform.localScale = Vector3.one;
objectCache[baseAdapter.getItemId(overIndex)] = obj; // 计入缓存
}
overIndex++;
if (overIndex >= baseAdapter.getCount()) { // 全部项目更新完成
updateFlag = false;
while (overIndex < prevCount) { // 更新项目少于已有的项目
obj = objectCache[baseAdapter.getItemId(overIndex++)];
if (obj != null) { // 将不需要的项目隐去
LayoutElement e = obj.GetComponent() as LayoutElement;
if (e != null) {
e.ignoreLayout = true;
}
obj.SetActive(false);
}
}
prevCount = baseAdapter.getCount();
break;
}
}
} else { // 没有数据时不销毁子项,只是隐藏
foreach (GameObject obj in objectCache.Values) {
if (obj.activeSelf) {
obj.SetActive(false);
}
}
updateFlag = false;
}
}
}
这样一来可以减少因为列表项数量变化引起的销毁,也在一定程度上减少了新建项目的次数。
到这里,列表组件就设计好了,这个组件可以被应用到任何需要列表GUI的地方,实际项目使用过程中还是比较方便的。当然这个列表的缺陷不少,它没有表头和表尾的设置,初始化时会将初始列表的所有项目全部创建出来,即便是分成60帧来分别创建也无法在项目很多时保证效率。
针对效率问题,将会在后续文章中详细讨论。
总体来说,该列表组件能满足一般性的列表展示需求,并且可以用于实际项目,基本达到了设计目标。