Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码


文章目录

      • 一、前言
      • 二、原理
      • 三、最终实现效果
      • 四、具体使用
        • 1、循环列表脚本:RecyclingListView
        • 2、列表item脚本:RecyclingListViewItem
        • 3、测试脚本
      • 五、附录
        • 1、RecyclingListView.cs
        • 2、RecyclingListViewItem.cs

一、前言
点关注不迷路,持续输出Unity干货文章。
嗨,大家好,我是新发。
游戏开发中,经常需要用到列表显示,比如排行榜列表、邮件列表、好友列表等等,而当列表数据很多时,我们就要考虑使用循环复用列表了,比如循环复用使用10个item来显示100个数据。
二、原理
原理其实就是,列表向上滑动时,当item超过显示区域的上边界时,把item移动到列表底部,重复使用item并更新itemui显示,向下滑动同理,把超过显示区域底部的item复用到顶部。
为了方便大家理解,我成图,如下:
Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码
文章图片

三、最终实现效果
本文Unity Demo工程我已上传到CodeChina,感兴趣的同学可自行下载学习。
地址:https://codechina.csdn.net/linxinfa/UnityRecyclingListDemo
注,我使用的Unity版本:2020.2.7f1c1 (64-bit)
Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码
文章图片

运行效果如下:
创建列表:
Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码
文章图片

使用9item循环复用,可以显示大量的列表项。
Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码
文章图片

清空列表:
Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码
文章图片

添加行:
Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码
文章图片

删除指定行:
Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码
文章图片

移动指定行到列表的顶部、中间、底部:
Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码
文章图片

四、具体使用
1、循环列表脚本:RecyclingListView RecyclingListView:循环列表类,具体代码参见文章末尾。
Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码
文章图片

主要接口和属性:
/// /// 列表行数,赋值时,会执行列表重新计算 /// public int RowCount/// /// 供外部调用,强制刷新整个列表,比如数据变化了,刷新一下列表 /// public virtual void Refresh()/// /// 供外部调用,强制刷新整个列表的局部item /// /// 开始行 /// 数量 public virtual void Refresh(int rowStart, int count)/// /// 供外部调用,强制刷新整个列表的某一个item /// public virtual void Refresh(RecyclingListViewItem item)/// /// 清空列表 /// public virtual void Clear()/// /// 供外部调用,强制滚动列表,使某一行显示在列表中 /// /// 行号 /// 目标行显示在列表的位置:顶部,中心,底部 public virtual void ScrollToRow(int row, ScrollPosType posType)

RecyclingListView脚本需要挂在ScrollRect所在的节点上。
Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码
文章图片

有三个参数:
Child Obj:列表项itemItem必须挂RecyclingListViewItem或它的子类脚本。
Row Padding:列表item之间的间隔。
Pre Alloc Height:预先分配列表高度,默认为0。
2、列表item脚本:RecyclingListViewItem 列表item基类,主要接口和属性:
/// /// 循环列表 /// public RecyclingListView ParentList /// /// 行号 /// public int CurrentRowpublic RectTransform RectTransform/// /// item更新事件响应函数 /// public virtual void NotifyCurrentAssignment(RecyclingListView v, int row)

具体列表item需要继承RecyclingListViewItem,在子类中实现具体更新item逻辑,例:
using UnityEngine.UI; public class TestChildItem : RecyclingListViewItem { public Text titleText; public Text rowText; private TestChildData childData; public TestChildData ChildData { get { return childData; } set { childData = https://www.it610.com/article/value; titleText.text = childData.Title; rowText.text = $"行号:{childData.Row}"; } } }public struct TestChildData { public string Title; public int Row; public TestChildData(string title, int row) { Title = title; Row = row; } }

列表item挂上TestChildItem脚本。
Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码
文章图片

3、测试脚本 做个简单的测试界面:
Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码
文章图片

写个测试脚本TestPanel,测试一下循环列表,挂在TestPanel节点上,赋值对应的ui对象。
Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码
文章图片

TestPanel代码如下:
using System.Collections.Generic; using UnityEngine; using Random = UnityEngine.Random; using UnityEngine.UI; public class TestPanel : MonoBehaviour { public RecyclingListView scrollList; /// /// 列表数据 /// private List data = https://www.it610.com/article/new List(); public InputField createRowCntInput; public Button createListBtn; public Button clearListBtn; public InputField deleteItemInput; public Button deleteItemBtn; public Button addItemBtn; public InputField moveToRowInput; public Button moveToTopBtn; public Button moveToCenterBtn; public Button moveToBottomBtn; private void Start() { // 列表item更新回调 scrollList.ItemCallback = PopulateItem; // 创建列表 createListBtn.onClick.AddListener(CreateList); // 清空列表 clearListBtn.onClick.AddListener(ClearList); // 删除某一行 deleteItemBtn.onClick.AddListener(DeleteItem); // 添加行 addItemBtn.onClick.AddListener(AddItem); // 将目标行移动到列表的顶部、中心、底部 moveToTopBtn.onClick.AddListener(() => { MoveToRow(RecyclingListView.ScrollPosType.Top); }); moveToCenterBtn.onClick.AddListener(() => { MoveToRow(RecyclingListView.ScrollPosType.Center); }); moveToBottomBtn.onClick.AddListener(() => { MoveToRow(RecyclingListView.ScrollPosType.Bottom); }); }/// /// item更新回调 /// /// 复用的item对象 /// 行号 private void PopulateItem(RecyclingListViewItem item, int rowIndex) { var child = item as TestChildItem; child.ChildData = https://www.it610.com/article/data[rowIndex]; }private void CreateList() { if (string.IsNullOrEmpty(createRowCntInput.text)) { Debug.LogError("请输入行数"); return; } var rowCnt = int.Parse(createRowCntInput.text); data.Clear(); // 模拟数据 string[] randomTitles = new[] { "黄沙百战穿金甲,不破楼兰终不还", "且将新火试新茶,诗酒趁年华", "苟利国家生死以,岂因祸福避趋之", "枫叶经霜艳,梅花透雪香", "夏虫不可语于冰", "落花无言,人淡如菊", "宠辱不惊,闲看庭前花开花落;去留无意,漫随天外云卷云舒", "衣带渐宽终不悔,为伊消得人憔悴", "从善如登,从恶如崩", "欲穷千里目,更上一层楼", "草木本无意,荣枯自有时", "纸上得来终觉浅,绝知此事要躬行", "不是一番梅彻骨,怎得梅花扑鼻香", "青青子衿,悠悠我心", "瓜田不纳履,李下不正冠" }; for (int i = 0; i < rowCnt; ++i) { data.Add(new TestChildData(randomTitles[Random.Range(0, randomTitles.Length)], i)); }// 设置数据,此时列表会执行更新 scrollList.RowCount = data.Count; }private void ClearList() { data.Clear(); scrollList.Clear(); }private void DeleteItem() { if (string.IsNullOrEmpty(deleteItemInput.text)) { Debug.LogError("请输入行号"); return; } var rowIndex = int.Parse(deleteItemInput.text); data.RemoveAll(item => (item.Row == rowIndex)); scrollList.RowCount = data.Count; }private void AddItem() { data.Add(new TestChildData("我是新增的行", data.Count)); scrollList.RowCount = data.Count; }private void MoveToRow(RecyclingListView.ScrollPosType posType) { if (string.IsNullOrEmpty(moveToRowInput.text)) { Debug.LogError("请输入行号"); return; } var rowIndex = int.Parse(moveToRowInput.text); scrollList.ScrollToRow(rowIndex, posType); } }

测试效果见文章最上面。
完毕。
喜欢Unity的同学,不要忘记点击关注,如果有什么Unity相关的技术难题,也欢迎留言或私信~
五、附录
脚本注释我写的比较清楚了,大家应该可以看懂。如果不懂怎么用的,可以下载我的Demo工程进行学习。
1、RecyclingListView.cs
using System; using UnityEngine; using UnityEngine.UI; /// /// 循环复用列表 /// [RequireComponent(typeof(ScrollRect))] public class RecyclingListView : MonoBehaviour { [Tooltip("子节点物体")] public RecyclingListViewItem ChildObj; [Tooltip("行间隔")] public float RowPadding = 15f; [Tooltip("事先预留的最小列表高度")] public float PreAllocHeight = 0; public enum ScrollPosType { Top, Center, Bottom, }public float VerticalNormalizedPosition { get => scrollRect.verticalNormalizedPosition; set => scrollRect.verticalNormalizedPosition = value; }/// /// 列表行数 /// protected int rowCount; /// /// 列表行数,赋值时,会执行列表重新计算 /// public int RowCount { get => rowCount; set { if (rowCount != value) { rowCount = value; // 先禁用滚动变化 ignoreScrollChange = true; // 更新高度 UpdateContentHeight(); // 重新启用滚动变化 ignoreScrollChange = false; // 重新计算item ReorganiseContent(true); } } }/// /// item更新回调函数委托 /// /// 子节点对象 /// 行数 public delegate void ItemDelegate(RecyclingListViewItem item, int rowIndex); /// /// item更新回调函数委托 /// public ItemDelegate ItemCallback; protected ScrollRect scrollRect; /// /// 复用的item数组 /// protected RecyclingListViewItem[] childItems; /// /// 循环列表中,第一个item的索引,最开始每个item都有一个原始索引,最顶部的item的原始索引就是childBufferStart /// 由于列表是循环复用的,所以往下滑动时,childBufferStart会从0开始到n,然后又从0开始,以此往复 /// 如果是往上滑动,则是从0到-n,再从0开始,以此往复 /// protected int childBufferStart = 0; /// /// 列表中最顶部的item的真实数据索引,比如有一百条数据,复用10个item,当前最顶部是第60条数据,那么sourceDataRowStart就是59(注意索引从0开始) /// protected int sourceDataRowStart; protected bool ignoreScrollChange = false; protected float previousBuildHeight = 0; protected const int rowsAboveBelow = 1; protected virtual void Awake() { scrollRect = GetComponent>(); ChildObj.gameObject.SetActive(false); }protected virtual void OnEnable() { scrollRect.onValueChanged.AddListener(OnScrollChanged); ignoreScrollChange = false; }protected virtual void OnDisable() { scrollRect.onValueChanged.RemoveListener(OnScrollChanged); }/// /// 供外部调用,强制刷新整个列表,比如数据变化了,刷新一下列表 /// public virtual void Refresh() { ReorganiseContent(true); }/// /// 供外部调用,强制刷新整个列表的局部item /// /// 开始行 /// 数量 public virtual void Refresh(int rowStart, int count) { int sourceDataLimit = sourceDataRowStart + childItems.Length; for (int i = 0; i < count; ++i) { int row = rowStart + i; if (row < sourceDataRowStart || row >= sourceDataLimit) continue; int bufIdx = WrapChildIndex(childBufferStart + row - sourceDataRowStart); if (childItems[bufIdx] != null) { UpdateChild(childItems[bufIdx], row); } } }/// /// 供外部调用,强制刷新整个列表的某一个item /// public virtual void Refresh(RecyclingListViewItem item) {for (int i = 0; i < childItems.Length; ++i) { int idx = WrapChildIndex(childBufferStart + i); if (childItems[idx] != null && childItems[idx] == item) { UpdateChild(childItems[i], sourceDataRowStart + i); break; } } }/// /// 清空列表 /// public virtual void Clear() { RowCount = 0; }/// /// 供外部调用,强制滚动列表,使某一行显示在列表中 /// /// 行号 /// 目标行显示在列表的位置:顶部,中心,底部 public virtual void ScrollToRow(int row, ScrollPosType posType) { scrollRect.verticalNormalizedPosition = GetRowScrollPosition(row, posType); }/// /// 获得归一化的滚动位置,该位置将给定的行在视图中居中 /// /// 行号 /// public float GetRowScrollPosition(int row, ScrollPosType posType) { // 视图高 float vpHeight = ViewportHeight(); float rowHeight = RowHeight(); // 将目标行滚动到列表目标位置时,列表顶部的位置 float vpTop = 0; switch (posType) { case ScrollPosType.Top: { vpTop = row * rowHeight; } break; case ScrollPosType.Center: { // 目标行的中心位置与列表顶部的距离 float rowCentre = (row + 0.5f) * rowHeight; // 视口中心位置 float halfVpHeight = vpHeight * 0.5f; vpTop = Mathf.Max(0, rowCentre - halfVpHeight); } break; case ScrollPosType.Bottom: { vpTop = (row+1) * rowHeight - vpHeight; } break; }// 滚动后,列表底部的位置 float vpBottom = vpTop + vpHeight; // 列表内容总高度 float contentHeight = scrollRect.content.sizeDelta.y; // 如果滚动后,列表底部的位置已经超过了列表总高度,则调整列表顶部的位置 if (vpBottom > contentHeight) vpTop = Mathf.Max(0, vpTop - (vpBottom - contentHeight)); // 反插值,计算两个值之间的Lerp参数。也就是value在from和to之间的比例值 return Mathf.InverseLerp(contentHeight - vpHeight, 0, vpTop); }/// /// 根据行号获取复用的item对象 /// /// 行号 protected RecyclingListViewItem GetRowItem(int row) { if (childItems != null && row >= sourceDataRowStart && row < sourceDataRowStart + childItems.Length && row < rowCount) { // 注意这里要根据行号计算复用的item原始索引 return childItems[WrapChildIndex(childBufferStart + row - sourceDataRowStart)]; }return null; }protected virtual bool CheckChildItems() { // 列表视口高度 float vpHeight = ViewportHeight(); float buildHeight = Mathf.Max(vpHeight, PreAllocHeight); bool rebuild = childItems == null || buildHeight > previousBuildHeight; if (rebuild) {int childCount = Mathf.RoundToInt(0.5f + buildHeight / RowHeight()); childCount += rowsAboveBelow * 2; if (childItems == null) childItems = new RecyclingListViewItem[childCount]; else if (childCount > childItems.Length) Array.Resize(ref childItems, childCount); // 创建item for (int i = 0; i < childItems.Length; ++i) { if (childItems[i] == null) { var item = Instantiate(ChildObj); childItems[i] = item; } childItems[i].RectTransform.SetParent(scrollRect.content, false); childItems[i].gameObject.SetActive(false); }previousBuildHeight = buildHeight; }return rebuild; }/// /// 列表滚动时,会回调此函数 /// /// 归一化的位置 protected virtual void OnScrollChanged(Vector2 normalisedPos) { if (!ignoreScrollChange) { ReorganiseContent(false); } }/// /// 重新计算列表内容 /// /// 【Unity3D|【游戏开发实战】Unity UGUI实现循环复用列表,显示巨量列表信息,含Demo工程源码】是否要清空列表重新计算 protected virtual void ReorganiseContent(bool clearContents) {if (clearContents) { scrollRect.StopMovement(); scrollRect.verticalNormalizedPosition = 1; }bool childrenChanged = CheckChildItems(); // 是否要更新整个列表 bool populateAll = childrenChanged || clearContents; float ymin = scrollRect.content.localPosition.y; // 第一个可见item的索引 int firstVisibleIndex = (int)(ymin / RowHeight()); int newRowStart = firstVisibleIndex - rowsAboveBelow; // 滚动变化量 int diff = newRowStart - sourceDataRowStart; if (populateAll || Mathf.Abs(diff) >= childItems.Length) {sourceDataRowStart = newRowStart; childBufferStart = 0; int rowIdx = newRowStart; foreach (var item in childItems) { UpdateChild(item, rowIdx++); }} else if (diff != 0) { int newBufferStart = (childBufferStart + diff) % childItems.Length; if (diff < 0) { // 向前滑动 for (int i = 1; i <= -diff; ++i) { // 得到复用item的索引 int wrapIndex = WrapChildIndex(childBufferStart - i); int rowIdx = sourceDataRowStart - i; UpdateChild(childItems[wrapIndex], rowIdx); } } else { // 向后滑动 int prevLastBufIdx = childBufferStart + childItems.Length - 1; int prevLastRowIdx = sourceDataRowStart + childItems.Length - 1; for (int i = 1; i <= diff; ++i) { int wrapIndex = WrapChildIndex(prevLastBufIdx + i); int rowIdx = prevLastRowIdx + i; UpdateChild(childItems[wrapIndex], rowIdx); } }sourceDataRowStart = newRowStart; childBufferStart = newBufferStart; } }private int WrapChildIndex(int idx) { while (idx < 0) idx += childItems.Length; return idx % childItems.Length; }/// /// 获取一行的高度,注意要加上RowPadding /// private float RowHeight() { return RowPadding + ChildObj.RectTransform.rect.height; }/// /// 获取列表视口的高度 /// private float ViewportHeight() { return scrollRect.viewport.rect.height; }protected virtual void UpdateChild(RecyclingListViewItem child, int rowIdx) { if (rowIdx < 0 || rowIdx >= rowCount) { child.gameObject.SetActive(false); } else { if (ItemCallback == null) { Debug.Log("RecyclingListView is missing an ItemCallback, cannot function", this); return; }// 移动到正确的位置 var childRect = ChildObj.RectTransform.rect; Vector2 pivot = ChildObj.RectTransform.pivot; float ytoppos = RowHeight() * rowIdx; float ypos = ytoppos + (1f - pivot.y) * childRect.height; float xpos = 0 + pivot.x * childRect.width; child.RectTransform.anchoredPosition = new Vector2(xpos, -ypos); child.NotifyCurrentAssignment(this, rowIdx); // 更新数据 ItemCallback(child, rowIdx); child.gameObject.SetActive(true); } }/// /// 更新content的高度 /// protected virtual void UpdateContentHeight() { // 列表高度 float height = ChildObj.RectTransform.rect.height * rowCount + (rowCount - 1) * RowPadding; // 更新content的高度 var sz = scrollRect.content.sizeDelta; scrollRect.content.sizeDelta = new Vector2(sz.x, height); }protected virtual void DisableAllChildren() { if (childItems != null) { for (int i = 0; i < childItems.Length; ++i) { childItems[i].gameObject.SetActive(false); } } } }

2、RecyclingListViewItem.cs
using UnityEngine; /// /// 列表item,你自己写的列表item需要继承该类 /// [RequireComponent(typeof(RectTransform))] public class RecyclingListViewItem : MonoBehaviour {private RecyclingListView parentList; /// /// 循环列表 /// public RecyclingListView ParentList { get => parentList; }private int currentRow; /// /// 行号 /// public int CurrentRow { get => currentRow; }private RectTransform rectTransform; public RectTransform RectTransform { get { if (rectTransform == null) rectTransform = GetComponent(); return rectTransform; } }private void Awake() { rectTransform = GetComponent(); }/// /// item更新事件响应函数 /// public virtual void NotifyCurrentAssignment(RecyclingListView v, int row) { parentList = v; currentRow = row; } }

    推荐阅读