UGUI 自定义滚动选择列表 ListView

列表在游戏的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的pivotanchorMaxanchorMin都设置为**(0,1)**。(要是有其他角做起点的奇葩设计,可以自行修改下代码)


接着,我们来创建一个简单的Item模板,当做该ListView要显示的Item,例如:

我们再创建一个脚本组件,ListViewItem.cs用来处理我们的Item,具体代码如下:

1[RequireComponent(typeof(Button))] 2public abstract class ListViewItem : MonoBehaviour 3{ 4 [SerializeField] GameObject m_selectedGameObject; 5 6 public ListView.ESelectType selectType { get; private set; } 7 Action<ListViewItem> m_onValueChanged; 8 Action<ListViewItem> m_onClicked;//适用于只在Item被单击时做操作的情况 9 RectTransform m_rectTransform; 10 Button m_button; 11 bool m_isSelected; 12 13 public bool isSelected 14 { 15 get => m_isSelected; 16 set 17 { 18 if (m_isSelected != value) 19 { 20 m_isSelected = value; 21 UpdateSelectedUI(); 22 } 23 } 24 } 25 void Awake() 26 { 27 m_button = GetComponent<Button>(); 28 isSelected = false; 29 m_button.onClick.AddListener(OnClicked); 30 31 m_rectTransform = GetComponent<RectTransform>(); 32 m_rectTransform.anchorMin = Vector2.up; 33 m_rectTransform.anchorMax = Vector2.up; 34 m_rectTransform.pivot = new Vector2(0.5f, 0.5f); 35 } 36 public void Init(ListView.ESelectType type, Action<ListViewItem> onValueChanged, Action<ListViewItem> onClicked) 37 { 38 selectType = type; 39 m_onValueChanged = onValueChanged; 40 m_onClicked = onClicked; 41 } 42 void OnClicked() 43 { 44 bool isValueChange = false; 45 if (selectType == ListView.ESelectType.Single) 46 { 47 if (!isSelected) 48 isValueChange = true; 49 isSelected = true; 50 } 51 else 52 { 53 isValueChange = true; 54 isSelected = !isSelected; 55 } 56 if(isValueChange) 57 m_onValueChanged?.Invoke(this); 58 m_onClicked?.Invoke(this); 59 } 60 protected virtual void UpdateSelectedUI() 61 { 62 if (m_selectedGameObject != null) 63 m_selectedGameObject.SetActive(isSelected); 64 } 65}

首先这是一个抽象类,因为不同功能下的Item的内容都是不一样的,例如服务器列表的Item可能只显示一个服务器名称,而商城列表的Item需要显示Icon,名称,价格等等。因此针对具体的Item需要具体的继承实现,例如例子中的GoodsItem,同时挂载在模板上的组件也是继承后的Item类:

1public class GoodsItem : ListViewItem 2{ 3 [SerializeField] Text m_nameText; 4 [SerializeField] Text m_priceText; 5 6 public GoodsData goodsData { get; private set; } 7 8 public void Init(GoodsData data) 9 { 10 goodsData = data; 11 m_nameText.text = data.name; 12 m_priceText.text = data.price.ToString(); 13 } 14}

接着回到ListViewItem中,在里面我们添加一个bool值isSelected用来管理Item是否选中的状态,同时为了方便计算pivot设置为**(0.5,0.5)anchorMaxanchorMin设置为(0,1)。在按钮被点击时,处理点击事件以及是否选中的状态改变事件**。


然后我们需要根据我们的Item模板以及数据,生成一个个我们需要的Item,放在ListView中,同时Item也要显示相对应的数据。

通常情况,在显示显示Item之前,我们都生成好了相应的数据。而ListView只需要关心我有几个Item,而不需要关心具体的数据,因为有关Item显示数据的逻辑会在ListView外(也就是设置ListView的类中)去实现。因此我们只需要通过设置ListView中Item的数量即可,每次设置即会显示对应数量的Item,以及刷新所有Item,重置选中的Item(具体要求可以视需求做修改)

1public int itemCount 2{ 3 get => m_itemList.Count; 4 set 5 { 6 ResetPosition(); 7 ClearAllSelectedItem(); 8 int oldCount = m_itemList.Count; 9 if (value > oldCount) 10 { 11 for (int i = oldCount; i < value; i++) 12 AddItem(); 13 } 14 else 15 RemoveItem(value, oldCount - 1); 16 Refresh(); 17 } 18} 19 20public void Refresh() 21{ 22 for (int i = 0, count = m_itemList.Count; i < count; i++) 23 { 24 ListViewItem item = m_itemList[i]; 25 m_onItemRefresh?.Invoke(i, item); 26 } 27}

我们使用m_itemList(List<ListViewItem>)来存放我们的Item,每次数量更新时,多的丢到对象池中,少的从对象池中取出来。然后调用m_onItemRefresh的委托,该委托的作用就是外部根据对应数据刷新Item。

1void OnItemRefresh(int index, ListViewItem item) 2{ 3 GoodsItem goodsItem = item as GoodsItem; 4 goodsItem.Init(currentList[index]); 5}

需要的Item都生成好了后,我们还需要将他们进行位置的摆放,否则都挤在一起了。

首先,我们需要知道显示的窗口大小,以及每个Item的大小。同时如果是垂直滚动,我们需要知道每行显示多少个Item,如果是水平滚动,那就需要知道每列显示多少个Item:

1void GetLayoutAttribute() 2{ 3 m_itemSize = itemPrefab.GetComponent<RectTransform>().rect.size; 4 m_initialSize = m_rectTransform.parent.GetComponent<RectTransform>().rect.size;//Viewport Size 5 //计算行或列 6 if (m_flowType == EFlowType.Horizontal) 7 { 8 m_rowCount = m_constraintCount; 9 if (m_rowCount <= 0) 10 m_rowCount = Mathf.FloorToInt((m_initialSize.y + m_itemSpace.y) / (m_itemSize.y + m_itemSpace.y)); 11 if (m_rowCount == 0) 12 m_rowCount = 1; 13 } 14 else 15 { 16 m_columnCount = m_constraintCount; 17 if (m_columnCount <= 0) 18 m_columnCount = Mathf.FloorToInt((m_initialSize.x + m_itemSpace.x) / (m_itemSize.x + m_itemSpace.x)); 19 if (m_columnCount == 0) 20 m_columnCount = 1; 21 } 22}

知道这些后,我们就可以根据Item的数量,计算出整个ListView的大小,根据下标知道每个Item在第几行第几列,设置其位置。我们新增一个bool值 m_isBoundDirty ,当item的数量发生变化时,将该值设为true,然后在Update中,检测到其值为true时,刷新ListView的大小以及每个Item的位置(此时左上角对齐的好处,简化了我们的运算)。

1void UpdateBounds() 2{ 3 SetSize(); 4 for (int i = 0, count = itemCount; i < count; i++) 5 m_itemList[i].transform.localPosition = CalculatePosition(i); 6 7 m_isBoundsDirty = false; 8} 9 10Vector2 CalculatePosition(int index) 11{ 12 int row, column; 13 if (m_flowType == EFlowType.Horizontal) 14 { 15 row = index % m_rowCount; 16 column = index / m_rowCount; 17 } 18 else 19 { 20 row = index / m_columnCount; 21 column = index % m_columnCount; 22 } 23 24 float x = column * (m_itemSize.x + m_itemSpace.x) + m_itemSize.x / 2; 25 float y = row * (m_itemSize.y + m_itemSpace.y) + m_itemSize.y / 2; 26 27 return new Vector2(x, -y); 28}

现在只剩下Item选中状态的处理了,单个Item的选中状态我们在ListViewItem中按钮的点击事件进行了处理,但是多个Item(例如选中另一个时,要取消之前选中的Item)以及状态改变时的委托,我们在ListView中进行处理(m_selectedItemList即存放选中的Item):

1void OnValueChanged(ListViewItem item) 2{ 3 if (item.isSelected) 4 { 5 if (m_selectType == ESelectType.Single) 6 { 7 if (m_selectedItemList.Count > 0) 8 { 9 //取消之前的选中状态 10 m_selectedItemList[0].isSelected = false; 11 int lastSelectedIndex = m_itemList.IndexOf(m_selectedItemList[0]); 12 m_onItemValueChanged?.Invoke(lastSelectedIndex, false); 13 m_selectedItemList.Clear(); 14 } 15 m_selectedItemList.Add(item); 16 } 17 else 18 m_selectedItemList.Add(item); 19 } 20 else 21 { 22 m_selectedItemList.Remove(item); 23 } 24 int index = m_itemList.IndexOf(item); 25 m_onItemValueChanged?.Invoke(index, item.isSelected); 26}

m_onItemValueChanged即是选中状态修改的委托,在外部可以用其做一些选中时的逻辑处理,例如:

1void OnItemValueChange(int index, bool isSelected) 2{ 3 GoodsData data = currentList[index]; 4 m_amount = m_amount + (isSelected ? 1 : -1) * data.price; 5 m_amountText.text = $"总额:{m_amount}"; 6}

这样一个简单的ListView基本就实现了,我们可以通过初始化方法,来进行一些参数的设置,类似Item模板,委托等:

1public void Init(GameObject prefab, Action<int, ListViewItem> refresh, Action<int, bool> valueChanged, Action<ListViewItem> clicked) 2{ 3 if (prefab.GetComponent<ListViewItem>() == null) 4 { 5 Debug.LogError("ListView prefab dont have ListViewItem Component"); 6 return; 7 } 8 itemPrefab = prefab; 9 m_pool = new GameObjectPool(m_rectTransform, itemPrefab); 10 m_onItemRefresh = refresh; 11 m_onItemValueChanged = valueChanged; 12 m_onItemClicked = clicked; 13 14 GetLayoutAttribute(); 15}

在外部我们只需要调用Init方法,然后设置itemCount即可显示我们的ListView

1m_listView.Init(m_goodsItemPrefab, OnItemRefresh, OnItemValueChange, OnItemClick); 2m_listView.onSelectedItemCleared = OnListViewSelectedItemCleared; 3m_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的数量和我们数据的数量相等。

1class ItemInfo 2{ 3 public ListViewItem item; 4 public bool isSelected; 5}

例如假设我们下标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进行相应的处理:

1public int itemCount 2{ 3 get => m_isVirtual ? m_itemRealCount : m_itemList.Count; 4 set 5 { 6 if (m_isVirtual) 7 { 8 m_itemRealCount = value; 9 SetSize(); 10 int oldCount = m_itemInfoList.Count; 11 if (m_itemRealCount > oldCount) 12 { 13 for (int i = oldCount; i < m_itemRealCount; i++) 14 { 15 ItemInfo info = new ItemInfo(); 16 m_itemInfoList.Add(info); 17 } 18 } 19 else 20 { 21 for (int i = m_itemRealCount; i < oldCount; i++) 22 { 23 if (m_itemInfoList[i].item != null) 24 { 25 RemoveItem(m_itemInfoList[i].item); 26 m_itemInfoList[i].item = null; 27 } 28 } 29 } 30 } 31 } 32}

接着就是最重要的,如何实现Item的复用的问题了。我的思路是,我们的ListView大小依旧按照真实数量来设置,这样在滚动ListView的时候,我们就可以根据ListView的偏移来计算出当前窗口应该显示的第一个Item所对应的下标m_startIndex

1int GetCurrentIndex(float position) 2{ 3 if (m_flowType == EFlowType.Horizontal) 4 { 5 position = -position; 6 if (position < m_itemSize.x) return 0; 7 position -= m_itemSize.x; 8 return (Mathf.FloorToInt(position / (m_itemSize.x + m_itemSpace.x)) + 1) * m_rowCount; 9 } 10 else 11 { 12 if (position < m_itemSize.y) return 0; 13 position -= m_itemSize.y; 14 return (Mathf.FloorToInt(position / (m_itemSize.y + m_itemSpace.y)) + 1) * m_columnCount; 15 } 16}

再根据窗口的大小,Item的大小以及Item的间距,计算出Item的结束下标m_endIndex,也就是从这个下标开始的Item是玩家看不到的。

1int oldStartIndex = m_startIndex, oldEndIndex = m_endIndex; 2if (m_flowType == EFlowType.Horizontal) 3{ 4 float currentX = m_rectTransform.localPosition.x;// <0 5 m_startIndex = GetCurrentIndex(currentX); 6 float endX = currentX - m_initialSize.x - m_itemSize.x - m_itemSpace.x; 7 m_endIndex = GetCurrentIndex(endX); 8} 9else 10{ 11 float currentY = m_rectTransform.localPosition.y;// >0 12 //上下滑动,根据listview的y值计算当前视图中第一个item的下标 13 m_startIndex = GetCurrentIndex(currentY); 14 //根据视图高度,item高度,间距的y,计算出结束行的下标 15 float endY = currentY + m_initialSize.y + m_itemSize.y + m_itemSpace.y; 16 m_endIndex = GetCurrentIndex(endY); 17} 18if(oldStartIndex == m_startIndex && oldEndIndex == m_endIndex) 19 return;

如果两个下标没变,那就说明用户看见的还是这些Item,就不需要改变。但是当有新的Item移入,或者旧的Item移出时,m_startIndex或者m_endIndex就会发生改变。当新的Index大于旧的说明列表是在往右或者往下滚动,我们可以从前面寻找可复用的Item,反之则从后寻找。若找不到可复用的Item,则生成新的Item。具体逻辑如下:

1//渲染当前视图内需要显示的item 2for (int i = m_startIndex; i < itemCount && i < m_endIndex; i++) 3{ 4 bool needRender = false;//是否需要刷新item ui 5 ItemInfo info = m_itemInfoList[i]; 6 if (info.item == null) 7 { 8 int j, jEnd; 9 if (oldStartIndex < m_startIndex || oldEndIndex < m_endIndex) 10 { 11 //说明是往下或者往右滚动,即要从前面找复用的Item 12 j = 0; 13 jEnd = m_startIndex; 14 } 15 else 16 { 17 j = m_endIndex; 18 jEnd = itemCount; 19 } 20 for (;j < jEnd; j++) 21 { 22 if (m_itemInfoList[j].item != null) 23 { 24 info.item = m_itemInfoList[j].item; 25 m_itemInfoList[j].item = null; 26 needRender = true; 27 break; 28 } 29 } 30 } 31 32 //前后找不到的话,添加新的item 33 if (info.item == null) 34 { 35 info.item = AddItem(); 36 needRender = true; 37 } 38 //更新位置,是否选中状态,以及数据 39 if (isForceRender || needRender) 40 { 41 info.item.transform.localPosition = CalculatePosition(i); 42 info.item.isSelected = info.isSelected; 43 m_onItemRefresh?.Invoke(i, info.item); 44 } 45}

这样就可以实现我们虚拟列表的功能了。

点赞
收藏

评论区

加载中...

相关推荐

MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1

文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s

Oracle 分组与拼接字符串同时使用

SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(

皕杰报表之UUID

​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为

2020年前端实用代码段,为你的工作保驾护航

有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )

PhoneGap设置Icon

参考:http://cordova.apache.org/docs/en/latest/config\_ref/images.html通过config.xml中的<icon标签来设置Icon<iconsrc"res/ios/icon.png"platform"ios"width"57"height"57"densi

Android如何实现一个上拉刷新下拉加载的ListView

20191220关键字:自定义上下拉ListView在APK开发中,一个具备在列表顶部下拉刷新、在列表尾部上拉加载功能的ListView的需求还是比较多的。具备这种功能的优秀开源代码同样也有很多。但今天,笔者就非要自己实现一个这样的控件不可。以下是成品效果图:!(https://oscimg.oschin