Unity—无限滚动复用列表
无限滚动复用列表
Demo展示
前言
游戏中有非常多的下拉滚动菜单,比如成就列表,任务列表,以及背包仓库之类;如果列表内容非常丰富,会占用大量内存,这篇无限滚动复用ScrollView就是解决这种问题;还可以用来做朋友圈,聊天等;
一般情况,ScrollView中每个Item的大小是一直的,使用ContentSizeFillter组件足够解决大部分问题;
如果每个Item大小不一致,问题就复杂起来,需要做滚动位置判断,我这里做了大小适应;
设计思路
1.将数据部分和滚动逻辑部分分离开,数据设计成泛型类;
2.在ScrollView组件上添加ScrollView脚本,控制Item的添加和删除,分为头部和尾部;
3.在每个Item上添加ScrollItem脚本,重写更新数据方法,同时监听自身是否为头部或者尾部;
4.如果为头部或者尾部,且超界通过委托调用ScrollView脚本中的添加或删除Item方法;
关键基类
1.ScrollData
负责整个列表的数据管理,分为总数据和现实数据两个链表,增删查改方法;泛型类方便复用;
这里使用LinkedList方便查找并返回头尾节点;
全部代码:
public class ScrollData<T>
{
public List<T> allDatas;
public LinkedList<T> curDatas;
public ScrollData()
{
allDatas = new List<T>();
curDatas = new LinkedList<T>();
//加载数据;
}
//获取头数据
public T GetHeadData()
{
if(allDatas.Count == 0)
return default(T);
if (curDatas.Count == 0)
{
T head = allDatas[0];
curDatas.AddFirst(head);
return head;
}
T t = curDatas.First.Value;
int index = allDatas.IndexOf(t);
if (index != 0)
{
T head = allDatas[index - 1];
curDatas.AddFirst(head);
return head;
}
return default(T);
}
//移出头数据
public bool RemoveHeadData()
{
if (curDatas.Count == 0 || curDatas.Count == 1)
return false;
curDatas.RemoveFirst();
return true;
}
//获取尾部数据
public T GetEndData()
{
if (allDatas.Count == 0)
return default(T);
if (curDatas.Count == 0)
{
T end = allDatas[0];
curDatas.AddLast(end);
return end;
}
T t = curDatas.Last.Value;
int index = allDatas.IndexOf(t);
if (index != allDatas.Count - 1)
{
T end = allDatas[index + 1];
curDatas.AddLast(end);
return end;
}
return default(T);
}
//移出尾部数据
public bool RemoveEndData()
{
if (curDatas.Count == 0 || curDatas.Count == 1)
return false;
curDatas.RemoveLast();
return true;
}
//添加数据,通过数组
public void AddData(T[] t)
{
allDatas.AddRange(t);
}
//添加数据,通过链表
public void AddData(List<T> t)
{
allDatas.AddRange(t.ToArray());
}
//添加单条数据
public void AddData(T t)
{
allDatas.Insert(0,t);
curDatas.AddFirst(t);
}
//情况当前显示节点
public void ClearCurData()
{
curDatas.Clear();
}
//获取当前显示链表的第一个数据在总数据中的下标
public int GetFirstIndex()
{
T t = curDatas.First.Value;
return allDatas.IndexOf(t);
}
}
2.ScrollView
关键字段:
scrollItemGo //每个Item的预制体
content //scrollRect下的Content
spacing //每个Item的间隔
isStart //是否第一次加载
方法:
GetChildItem;
1.获取一个Item的预制体,先从content的子物体中寻找active为false的物体,如果没有则根据scrollItemGo克隆一个;
2.创建新Item时,获取ScrollItem组件,赋值其中的参数(四个委托),并初始化;
OnAddHead;OnRemoveHead;OnAddEnd;OnRemoveEnd;
委托方法:
1.调用ScrollData中GetHeadData方法,获得头数据;
2.找到content中第一个节点;
3.调用GetChildItem方法获得item的实例;
4.SetAsFirstSibling,将实例设置为首节点,同时调用RefreshData,刷新数据;
5.根据item 的宽度做自适应(item大小相同,只选挂载ContentSizeFitter);
全部代码:
public class ScrollView : MonoBehaviour
{
public GameObject scrollItemGo;
private RectTransform content;
[SerializeField]
private float spacing;
private bool isStart = true;
void Start()
{
content = this.GetComponent<ScrollRect>().content;
spacing = 15;
OnAddHead();
}
private GameObject GetChildItem()
{
//查找是否有未回收的子节点
for (int i = 0; i < content.childCount; ++i)
{
GameObject tempGo = content.GetChild(i).gameObject;
if (!tempGo.activeSelf)
{
tempGo.SetActive(true);
return tempGo;
}
}
//无创建新的
GameObject childItem = GameObject.Instantiate<GameObject>(scrollItemGo,content.transform);
ScrollViewItem scrollItem = childItem.GetComponent<ScrollViewItem>();
if (scrollItem == null)
scrollItem = childItem.AddComponent<ScrollViewItem>();
scrollItem.onAddHead += OnAddHead;
scrollItem.onRemoveHead += OnRemoveHead;
scrollItem.onAddEnd += OnAddEnd;
scrollItem.onRemoveEnd += OnRemoveEnd;
scrollItem.Init();
childItem.GetComponent<RectTransform>().anchorMin = new Vector2(0.5f, 1);
childItem.GetComponent<RectTransform>().anchorMax = new Vector2(0.5f, 1);
childItem.GetComponent<RectTransform>().pivot = new Vector2(0, 1);
childItem.transform.localScale = Vector3.one;
childItem.transform.localPosition = Vector3.zero;
//-----设置宽高——加载数据
return childItem;
}
private void OnAddHead()
{
Data data = this.GetComponent<Test>().scrollData.GetHeadData();
if (data != null)
{
Transform first = FindFirst();
//----first 不为 数据头---在data中做了
GameObject obj = GetChildItem();
obj.GetComponent<ScrollViewItem>().RefreshData(data);
obj.transform.SetAsFirstSibling();
RectTransform objRect = obj.GetComponent<RectTransform>();
float height = objRect.sizeDelta.y;
if (first != null)
{
obj.transform.localPosition = first.localPosition + new Vector3(0, height + spacing, 0);
}
if (isStart)
{
content.sizeDelta += new Vector2(0, height + spacing);
isStart = false;
}
}
}
private void OnRemoveHead()
{
var scrollData = this.GetComponent<Test>().scrollData;
if (scrollData.RemoveHeadData())
{
Transform tf = FindFirst();
if (tf != null)
tf.gameObject.SetActive(false);
}
}
private void OnAddEnd()
{
Data data = this.GetComponent<Test>().scrollData.GetEndData();
if (data != null)
{
Transform end = FindEnd();
//----end 不为 数据尾在data中做了
GameObject obj = GetChildItem();
obj.transform.SetAsLastSibling();
obj.GetComponent<ScrollViewItem>().RefreshData(data);
float height = end.GetComponent<RectTransform>().sizeDelta.y;
if (end != null)
obj.transform.localPosition = end.localPosition - new Vector3(0, height + spacing, 0);
//是否增加content高度
if (IsAddContentH(obj.transform))
{
float h = obj.GetComponent<RectTransform>().sizeDelta.y;
content.sizeDelta += new Vector2(0, h + spacing);
}
}
}
private void OnRemoveEnd()
{
var scrollData = this.GetComponent<Test>().scrollData;
if (scrollData.RemoveEndData())
{
Transform tf = FindEnd();
if (tf != null)
tf.gameObject.SetActive(false);
}
}
private Transform FindFirst()
{
for (int i = 0; i < content.childCount; ++i)
{
if (content.GetChild(i).gameObject.activeSelf)
{
return content.GetChild(i);
}
}
return null;
}
private Transform FindEnd()
{
for (int i = content.childCount - 1; i >= 0; --i)
{
if (content.GetChild(i).gameObject.activeSelf)
{
return content.GetChild(i);
}
}
return null;
}
private bool IsAddContentH(Transform tf)
{
Vector3[] rectC = new Vector3[4];
Vector3[] contentC = new Vector3[4];
tf.GetComponent<RectTransform>().GetWorldCorners(rectC);
content.GetWorldCorners(contentC);
if (rectC[0].y < contentC[0].y)
return true;
return false;
}
}
3.ScrollItem
关键字段:四个委托
public Action onAddHead;
public Action onRemoveHead;
public Action onAddEnd;
public Action onRemoveEnd;
关键方法:
OnRecyclingItem;
1.判断自身是否为头尾节点;
2.判断自身是否超界,超界需要隐藏自身;
3.判断自身与边界距离,是否添加节点;
关键API:
RectTransform.GetWorldCorners(Vector3[4])
获取UI对象四个顶点的世界坐标,下标对应的位置;
全部代码:
public class ScrollViewItem : MonoBehaviour
{
private RectTransform viewRect;
private RectTransform rect;
[SerializeField]
private float viewStart;
[SerializeField]
private float viewEnd;
[SerializeField]
private Vector3[] rectCorners;
public Action onAddHead;
public Action onRemoveHead;
public Action onAddEnd;
public Action onRemoveEnd;
public Text nameT;
public Text inputT;
void Start()
{
Init();
}
public void Init()
{
viewRect = transform.parent.parent.GetComponent<RectTransform>();
rect = this.GetComponent<RectTransform>();
rectCorners = new Vector3[4];
viewRect.GetWorldCorners(rectCorners);
viewStart = rectCorners[1].y;
viewEnd = rectCorners[0].y;
}
void Update()
{
OnRecyclingItem();
}
//超界变false;
private void OnRecyclingItem()
{
rect = this.GetComponent<RectTransform>();
rectCorners = new Vector3[4];
rect.GetWorldCorners(rectCorners);
if (IsFirst())
{
if (rectCorners[0].y > viewStart)
{
//隐藏头节点
if (onRemoveHead != null)
onRemoveHead();
}
if (rectCorners[1].y < viewStart)
{
//添加头节点-头节点不为数据起始点
if (onAddHead != null)
onAddHead();
}
}
if (IsLast())
{
if (rectCorners[0].y > viewEnd)
{
//添加尾节点-尾节点不为数据末尾
if (onAddEnd != null)
onAddEnd();
}
if (rectCorners[1].y < viewEnd)
{
//隐藏尾节点
if (onRemoveEnd != null)
onRemoveEnd();
}
}
}
private bool IsFirst()
{
for (int i = 0; i < transform.parent.childCount; ++i)
{
Transform tf = transform.parent.GetChild(i);
if (tf.gameObject.activeSelf)
{
if (tf == this.transform)
{
return true;
}
break;
}
}
return false;
}
private bool IsLast()
{
for (int i = transform.parent.childCount-1; i >= 0 ; i--)
{
Transform tf = transform.parent.GetChild(i);
if (tf.gameObject.activeSelf)
{
if (tf == this.transform)
{
return true;
}
break;
}
}
return false;
}
public bool IsInView()
{
rect = this.GetComponent<RectTransform>();
rect.GetWorldCorners(rectCorners);
if (rectCorners[1].y > viewEnd || rectCorners[0].y < viewStart)
return false;
return true;
}
public void RefreshData(Data da)
{
nameT.text = da.name;
inputT.text = da.text;
Vector2 oldSize = rect.sizeDelta;
rect.sizeDelta = new Vector2(oldSize.x, 200 + da.h);
}
}
测试类
初始化数据,随机4中宽度的item;
void InitData()
{
int[] hArr = new int[4];
hArr[0] = 0;
hArr[1] = 190;
hArr[2] = 190 * 2;
hArr[3] = 190 * 3;
for (int i = 0; i < 30; ++i)
{
Data da = new Data();
da.name = "小紫苏" + i.ToString();
da.text = "000000" + i.ToString();
int index = UnityEngine.Random.Range(0, 3);
da.h = hArr[index];
scrollData.allDatas.Add(da);
}
}
添加三个按钮,及相应的响应方法;
1.添加20组数据
private void AddData()
{
int[] hArr = new int[4];
hArr[0] = 0;
hArr[1] = 190;
hArr[2] = 190 * 2;
hArr[3] = 190 * 3;
Data[] newData = new Data[20];
for (int i = 0; i < 20; ++i)
{
Data da = new Data();
da.name = "小紫苏" + i.ToString();
da.text = "000000" + i.ToString();
int index = UnityEngine.Random.Range(0, 3);
da.h = hArr[index];
newData[i] = da;
}
scrollDat
回到顶部或底部需要有过程,因此需要在update中运行,也可以用插值;
2.回到顶部
private void OnGoHead()
{
if (isGoHead)
isGoHead = false;
else
isGoHead = true;
}
private void OnGoLast()
{
if (isGoLast)
isGoLast = false;
else
isGoLast = true;
}
3.回到底部
private void GoHead()
{
if (!isGoHead)
return;
float curPos = scroll.verticalNormalizedPosition;
if (curPos != 1)
{
curPos += 0.01f;
if (curPos >= 1)
{
curPos = 1;
isGoHead = false;
}
scroll.verticalNormalizedPosition = curPos;
}
}
private void GoLast()
{
if (!isGoLast)
return;
float curPos = scroll.verticalNormalizedPosition;
if (curPos != 0)
{
curPos -= 0.01f;
if (curPos <= 0)
{
curPos = 0;
isGoLast = false;
}
scroll.verticalNormalizedPosition = curPos;
}
}
坑点
1.ScrollView回滚设置延迟;
回滚判断是通过verticalNormalizedPosition的API,更改这个值后需要间隔一帧才会修改,因为可能导致判断两次;
解决方法,Canvas.ForceUpdate;
2.锚点设置;
锚点的设置以及UI的自适应会直接影响项目回滚的方向和位置;
大部分位置出错都是因为锚点设置错误;
3.数据需要网络请求,自适应会失效;
网络数据一般都是异步,所以判断会做多次,因此数据上要求提前计算好item的宽度;
项目工程我上传到Gitee,可自行下载学习;https://gitee.com/small-perilla/scroll-view
以上是我对滚动复用组件的总结,如果有更好的意见,欢迎给作者评论留言;
Life is too short for so much sorrow.