列表在游戏的UI中是非常常见的,例如选服页面,商城页面,奖励页面等等都会有列表的存在。文中我们将这些列表称为ListView(类似于fgui的GList),而列表中的每项称作Item。
首先我们来分析下,我们的ListView需要实现哪些功能,以及如何实现
功能
解决思路
可以通过滑动来显示ListView中的Item
可以使用UGUI的ScrollView(ScrollRect)来实现,Item放在Content当中
ListView中的Item可以是单选或者多选
UGUI的Toggle可以为我们实现Item的选中和未选中状态,但是由于当ListView是单选的时候,我们希望同一个Item被多次点击的时候都保持选中状态,而不是选中、未选中来回切。因此我们通过拓展Button来实现。
Item的增加删除
使用对象池来管理Item,防止频繁的增删导致不断的添加或销毁GameObject造成的内存碎片化
ListView中的Item的排列,以及整体大小
UGUI的GridLayoutGroup可以为我们实现排列,ContentSizeFitter可以为我们实现大小的适配。不过由于一些局限性(不适应于虚拟列表),以及本身实现起来并不是很难,因此这两个我们都不使用,通过自己计算来实现。
ListView的数据切换,例如显示服装变为显示宠物
刷新所有的Item,重置选中的Item
虚拟列表
通常情况Item可能只有十几二十项,甚至更少,我们可以每个Item都对应一个GameObject。但是对于商城或者背包等,我们的Item可能是上百项的,如果按照传统的做法,就会有上百个GameObject,造成性能的消耗。
因此我们引用虚拟列表的概念,简单来说就是只渲染玩家看的见的Item,然后在滑动过程中通过刷新Item的内容,来使用户看起来是在浏览上百个Item(后续会详细介绍)。
当然除了虚拟列表的做法外,我们也可以使用分页的做法,每次滑动触发“翻页”的时候,刷新所有Item数据,达到跳转到下一页的效果(可后续拓展)。
Demo:https://github.com/luckyWjr/Demo (ListView目录)。
当然了,具体的功能还得看具体的需求,大家可以自行进行相应的变更。
简单的看下实现后的效果:
单选模式: 多选模式:
虚拟列表:(可以看见一共有40个Item,但是并没有生成40个GameObject)
前面思路大致理清后,接着就是上手来具体实现了。(代码量比较多,因此下面的介绍不会涉及到全部的代码,基本就是一个思路的介绍。想看完整代码的可以自行去看Demo)
首先我们先新建一个ScrollView,来替我们实现滚动的功能。ScrollView中的Content即是我们的Item容器,也就是ListView。
我们新建一个脚本组件,ListView.cs用于实现列表相关功能,将其挂载在Content上。然后为其添加一些需要设置的属性,例如:
Is Virtual
是否是虚拟列表
Select Type
单选or多选
Flow Type
垂直滚动or水平滚动
Constraint Count
行数或列数的限制,若小于等于0则不限制。(例如,在垂直滚动下,假设我们的容器每行可以容下4个Item,若设置为0,即按照每行4个排列,若设置为大于0的其他值,则按照每行设置的值排列)
Item Space
每个Item的上下间隔
大多数情况下,无论垂直滚动还是水平滚动,都是从左上角开始的,因此案例中我们将ListView的pivot,anchorMax,anchorMin都设置为**(0,1)**。(要是有其他角做起点的奇葩设计,可以自行修改下代码)
接着,我们来创建一个简单的Item模板,当做该ListView要显示的Item,例如:
我们再创建一个脚本组件,ListViewItem.cs用来处理我们的Item,具体代码如下:
[RequireComponent(typeof(Button))]
public abstract class ListViewItem : MonoBehaviour
{
[SerializeField] GameObject m_selectedGameObject;
public ListView.ESelectType selectType { get; private set; }
Action<ListViewItem> m_onValueChanged;
Action<ListViewItem> m_onClicked;//适用于只在Item被单击时做操作的情况
RectTransform m_rectTransform;
Button m_button;
bool m_isSelected;
public bool isSelected
{
get => m_isSelected;
set
{
if (m_isSelected != value)
{
m_isSelected = value;
UpdateSelectedUI();
}
}
}
void Awake()
{
m_button = GetComponent<Button>();
isSelected = false;
m_button.onClick.AddListener(OnClicked);
m_rectTransform = GetComponent<RectTransform>();
m_rectTransform.anchorMin = Vector2.up;
m_rectTransform.anchorMax = Vector2.up;
m_rectTransform.pivot = new Vector2(0.5f, 0.5f);
}
public void Init(ListView.ESelectType type, Action<ListViewItem> onValueChanged, Action<ListViewItem> onClicked)
{
selectType = type;
m_onValueChanged = onValueChanged;
m_onClicked = onClicked;
}
void OnClicked()
{
bool isValueChange = false;
if (selectType == ListView.ESelectType.Single)
{
if (!isSelected)
isValueChange = true;
isSelected = true;
}
else
{
isValueChange = true;
isSelected = !isSelected;
}
if(isValueChange)
m_onValueChanged?.Invoke(this);
m_onClicked?.Invoke(this);
}
protected virtual void UpdateSelectedUI()
{
if (m_selectedGameObject != null)
m_selectedGameObject.SetActive(isSelected);
}
}
首先这是一个抽象类,因为不同功能下的Item的内容都是不一样的,例如服务器列表的Item可能只显示一个服务器名称,而商城列表的Item需要显示Icon,名称,价格等等。因此针对具体的Item需要具体的继承实现,例如例子中的GoodsItem,同时挂载在模板上的组件也是继承后的Item类:
public class GoodsItem : ListViewItem
{
[SerializeField] Text m_nameText;
[SerializeField] Text m_priceText;
public GoodsData goodsData { get; private set; }
public void Init(GoodsData data)
{
goodsData = data;
m_nameText.text = data.name;
m_priceText.text = data.price.ToString();
}
}
接着回到ListViewItem中,在里面我们添加一个bool值isSelected用来管理Item是否选中的状态,同时为了方便计算pivot设置为**(0.5,0.5),anchorMax,anchorMin设置为(0,1)。在按钮被点击时,处理点击事件以及是否选中的状态改变事件**。
然后我们需要根据我们的Item模板以及数据,生成一个个我们需要的Item,放在ListView中,同时Item也要显示相对应的数据。
通常情况,在显示显示Item之前,我们都生成好了相应的数据。而ListView只需要关心我有几个Item,而不需要关心具体的数据,因为有关Item显示数据的逻辑会在ListView外(也就是设置ListView的类中)去实现。因此我们只需要通过设置ListView中Item的数量即可,每次设置即会显示对应数量的Item,以及刷新所有Item,重置选中的Item(具体要求可以视需求做修改)
public int itemCount
{
get => m_itemList.Count;
set
{
ResetPosition();
ClearAllSelectedItem();
int oldCount = m_itemList.Count;
if (value > oldCount)
{
for (int i = oldCount; i < value; i++)
AddItem();
}
else
RemoveItem(value, oldCount - 1);
Refresh();
}
}
public void Refresh()
{
for (int i = 0, count = m_itemList.Count; i < count; i++)
{
ListViewItem item = m_itemList[i];
m_onItemRefresh?.Invoke(i, item);
}
}
我们使用m_itemList(List
void OnItemRefresh(int index, ListViewItem item)
{
GoodsItem goodsItem = item as GoodsItem;
goodsItem.Init(currentList[index]);
}
需要的Item都生成好了后,我们还需要将他们进行位置的摆放,否则都挤在一起了。
首先,我们需要知道显示的窗口大小,以及每个Item的大小。同时如果是垂直滚动,我们需要知道每行显示多少个Item,如果是水平滚动,那就需要知道每列显示多少个Item:
void GetLayoutAttribute()
{
m_itemSize = itemPrefab.GetComponent<RectTransform>().rect.size;
m_initialSize = m_rectTransform.parent.GetComponent<RectTransform>().rect.size;//Viewport Size
//计算行或列
if (m_flowType == EFlowType.Horizontal)
{
m_rowCount = m_constraintCount;
if (m_rowCount <= 0)
m_rowCount = Mathf.FloorToInt((m_initialSize.y + m_itemSpace.y) / (m_itemSize.y + m_itemSpace.y));
if (m_rowCount == 0)
m_rowCount = 1;
}
else
{
m_columnCount = m_constraintCount;
if (m_columnCount <= 0)
m_columnCount = Mathf.FloorToInt((m_initialSize.x + m_itemSpace.x) / (m_itemSize.x + m_itemSpace.x));
if (m_columnCount == 0)
m_columnCount = 1;
}
}
知道这些后,我们就可以根据Item的数量,计算出整个ListView的大小,根据下标知道每个Item在第几行第几列,设置其位置。我们新增一个bool值 m_isBoundDirty ,当item的数量发生变化时,将该值设为true,然后在Update中,检测到其值为true时,刷新ListView的大小以及每个Item的位置(此时左上角对齐的好处,简化了我们的运算)。
void UpdateBounds()
{
SetSize();
for (int i = 0, count = itemCount; i < count; i++)
m_itemList[i].transform.localPosition = CalculatePosition(i);
m_isBoundsDirty = false;
}
Vector2 CalculatePosition(int index)
{
int row, column;
if (m_flowType == EFlowType.Horizontal)
{
row = index % m_rowCount;
column = index / m_rowCount;
}
else
{
row = index / m_columnCount;
column = index % m_columnCount;
}
float x = column * (m_itemSize.x + m_itemSpace.x) + m_itemSize.x / 2;
float y = row * (m_itemSize.y + m_itemSpace.y) + m_itemSize.y / 2;
return new Vector2(x, -y);
}
现在只剩下Item选中状态的处理了,单个Item的选中状态我们在ListViewItem中按钮的点击事件进行了处理,但是多个Item(例如选中另一个时,要取消之前选中的Item)以及状态改变时的委托,我们在ListView中进行处理(m_selectedItemList即存放选中的Item):
void OnValueChanged(ListViewItem item)
{
if (item.isSelected)
{
if (m_selectType == ESelectType.Single)
{
if (m_selectedItemList.Count > 0)
{
//取消之前的选中状态
m_selectedItemList[0].isSelected = false;
int lastSelectedIndex = m_itemList.IndexOf(m_selectedItemList[0]);
m_onItemValueChanged?.Invoke(lastSelectedIndex, false);
m_selectedItemList.Clear();
}
m_selectedItemList.Add(item);
}
else
m_selectedItemList.Add(item);
}
else
{
m_selectedItemList.Remove(item);
}
int index = m_itemList.IndexOf(item);
m_onItemValueChanged?.Invoke(index, item.isSelected);
}
m_onItemValueChanged即是选中状态修改的委托,在外部可以用其做一些选中时的逻辑处理,例如:
void OnItemValueChange(int index, bool isSelected)
{
GoodsData data = currentList[index];
m_amount = m_amount + (isSelected ? 1 : -1) * data.price;
m_amountText.text = $"总额:{m_amount}";
}
这样一个简单的ListView基本就实现了,我们可以通过初始化方法,来进行一些参数的设置,类似Item模板,委托等:
public void Init(GameObject prefab, Action<int, ListViewItem> refresh, Action<int, bool> valueChanged, Action<ListViewItem> clicked)
{
if (prefab.GetComponent<ListViewItem>() == null)
{
Debug.LogError("ListView prefab dont have ListViewItem Component");
return;
}
itemPrefab = prefab;
m_pool = new GameObjectPool(m_rectTransform, itemPrefab);
m_onItemRefresh = refresh;
m_onItemValueChanged = valueChanged;
m_onItemClicked = clicked;
GetLayoutAttribute();
}
在外部我们只需要调用Init方法,然后设置itemCount即可显示我们的ListView
m_listView.Init(m_goodsItemPrefab, OnItemRefresh, OnItemValueChange, OnItemClick);
m_listView.onSelectedItemCleared = OnListViewSelectedItemCleared;
m_listView.itemCount = m_shenbingDataList.Count;
接下来就是我们虚拟列表的处理了。按照上面的做法,假设我们有上百个Item,那么就会生成上百个GameObject,这显然是很不好的。一种处理方法就是分页(Demo中暂未添加该逻辑),例如我们一次可以看见12个Item,每次滑动的时候将这12个Item都进行刷新,类似翻页的效果,这样就可以保证几百个Item的时候只有12个GameObject,但是浏览起来就没有正常滚动时舒服。因此此时就可以使用我们虚拟列表的方法来完美的实现。
首先我们知道,即使有几百个Item,但是我们每次能看见的始终是窗口中显示的那几个,其余都是看不见的。而我们每次滚动的时候,无论如何都是会滚进来一行或一列新的Item,同时滚出去一行或一列旧的Item。那么我们如果将这些滚出去的旧的Item当做新的Item滚进来,像形成一个圈这样,然后刷新这部分Item所对应的数据,就可以实现以少量Item实现大量Item滚动的效果。(具体效果可以看文章最前面的GIF)
首先虽然我们的Item只有几个,但是仍然需要纪录真实每项的一些数据信息,我们称之为ItemInfo,ItemInfo的数量和我们数据的数量相等。
class ItemInfo
{
public ListViewItem item;
public bool isSelected;
}
例如假设我们下标2的Item被选中了(ListViewItem.isSelected = true),然后我们往后滚,当之前下标2对应的Item被复用时(此时的它可能对应着下标12),肯定不能还是之前的选中状态,而应该是未选中状态(ListViewItem.isSelected = false)。当我们再次显示下标2的Item的时候,那么怎么知道它之前是被选中的呢?那就是通过ItemInfo来处理,修改状态的时候修改对应下标的ItemInfo的isSelected的值,显示的时候读取对应下标ItemInfo的值即可。
又比如,在滚动刷新Item的时候,因为Item一直是被复用的,我们如果知道该下标下是否已经有对应的Item了,同样通过设置读取ItemInfo可以来解决。
和之前一样,我们同样通过设置ItemCount来刷新我们的ListView,我们在设置ItemCount时,对存储ItemInfo的List进行相应的处理:
public int itemCount
{
get => m_isVirtual ? m_itemRealCount : m_itemList.Count;
set
{
if (m_isVirtual)
{
m_itemRealCount = value;
SetSize();
int oldCount = m_itemInfoList.Count;
if (m_itemRealCount > oldCount)
{
for (int i = oldCount; i < m_itemRealCount; i++)
{
ItemInfo info = new ItemInfo();
m_itemInfoList.Add(info);
}
}
else
{
for (int i = m_itemRealCount; i < oldCount; i++)
{
if (m_itemInfoList[i].item != null)
{
RemoveItem(m_itemInfoList[i].item);
m_itemInfoList[i].item = null;
}
}
}
}
}
}
接着就是最重要的,如何实现Item的复用的问题了。我的思路是,我们的ListView大小依旧按照真实数量来设置,这样在滚动ListView的时候,我们就可以根据ListView的偏移来计算出当前窗口应该显示的第一个Item所对应的下标m_startIndex。
int GetCurrentIndex(float position)
{
if (m_flowType == EFlowType.Horizontal)
{
position = -position;
if (position < m_itemSize.x) return 0;
position -= m_itemSize.x;
return (Mathf.FloorToInt(position / (m_itemSize.x + m_itemSpace.x)) + 1) * m_rowCount;
}
else
{
if (position < m_itemSize.y) return 0;
position -= m_itemSize.y;
return (Mathf.FloorToInt(position / (m_itemSize.y + m_itemSpace.y)) + 1) * m_columnCount;
}
}
再根据窗口的大小,Item的大小以及Item的间距,计算出Item的结束下标m_endIndex,也就是从这个下标开始的Item是玩家看不到的。
int oldStartIndex = m_startIndex, oldEndIndex = m_endIndex;
if (m_flowType == EFlowType.Horizontal)
{
float currentX = m_rectTransform.localPosition.x;// <0
m_startIndex = GetCurrentIndex(currentX);
float endX = currentX - m_initialSize.x - m_itemSize.x - m_itemSpace.x;
m_endIndex = GetCurrentIndex(endX);
}
else
{
float currentY = m_rectTransform.localPosition.y;// >0
//上下滑动,根据listview的y值计算当前视图中第一个item的下标
m_startIndex = GetCurrentIndex(currentY);
//根据视图高度,item高度,间距的y,计算出结束行的下标
float endY = currentY + m_initialSize.y + m_itemSize.y + m_itemSpace.y;
m_endIndex = GetCurrentIndex(endY);
}
if(oldStartIndex == m_startIndex && oldEndIndex == m_endIndex)
return;
如果两个下标没变,那就说明用户看见的还是这些Item,就不需要改变。但是当有新的Item移入,或者旧的Item移出时,m_startIndex或者m_endIndex就会发生改变。当新的Index大于旧的说明列表是在往右或者往下滚动,我们可以从前面寻找可复用的Item,反之则从后寻找。若找不到可复用的Item,则生成新的Item。具体逻辑如下:
//渲染当前视图内需要显示的item
for (int i = m_startIndex; i < itemCount && i < m_endIndex; i++)
{
bool needRender = false;//是否需要刷新item ui
ItemInfo info = m_itemInfoList[i];
if (info.item == null)
{
int j, jEnd;
if (oldStartIndex < m_startIndex || oldEndIndex < m_endIndex)
{
//说明是往下或者往右滚动,即要从前面找复用的Item
j = 0;
jEnd = m_startIndex;
}
else
{
j = m_endIndex;
jEnd = itemCount;
}
for (;j < jEnd; j++)
{
if (m_itemInfoList[j].item != null)
{
info.item = m_itemInfoList[j].item;
m_itemInfoList[j].item = null;
needRender = true;
break;
}
}
}
//前后找不到的话,添加新的item
if (info.item == null)
{
info.item = AddItem();
needRender = true;
}
//更新位置,是否选中状态,以及数据
if (isForceRender || needRender)
{
info.item.transform.localPosition = CalculatePosition(i);
info.item.isSelected = info.isSelected;
m_onItemRefresh?.Invoke(i, info.item);
}
}
这样就可以实现我们虚拟列表的功能了。