UGUI扩展 - ListView 循环列表
运行效果

实现原理
1) 只生成裁剪区域所需的条目
2) 向上滑动时,顶部滑到裁剪区域外的条目回收放入缓冲池,底部即将滑入裁剪区域的条目从缓冲池获取后生成。
3) 向下滑动时,底部滑到裁剪区域外的条目回收放入缓冲池,顶部即将滑入裁剪区域的条目从缓冲池获取后生成。
原理图解
1) 只生成裁剪区域所需的条目
a) 黄色区域为裁剪区域(为了演示关闭了裁剪功能),裁剪区域也叫viewport(视口)
b) 条目5不在裁剪区域内,为啥也生成了?实际中为了防止穿帮,一般会多生成一条。

伪码
var content = m_ScrollRect.content; float tmpItemTopDis = 0; //条目top到content的top的距离 for (int i = 0; i < m_DataCount; ++i) { var newItemRtf = m_OnObtainItem(this, i); if (null == newItemRtf) break; newItemRtf.gameObject.SetActive(true); newItemRtf.SetParent(content, false); m_ItemNodeList.Add(new ItemNode(newItemRtf, i)); float itemSize = newItemRtf.rect.height; float itemBottomDis = tmpItemTopDis + itemSize; m_ItemPosAndSizeList.Add(new ItemPosAndSize(itemSize, itemBottomDis)); if (tmpItemTopDis > viewportSize) break; tmpItemTopDis = itemBottomDis + m_ItemSpace; }
2) 向上滑动时:
2-1) 顶部滑到裁剪区域外的条目回收放入缓冲池
a) 如何判断顶部滑到裁剪区域外? 条目的bottomDis < scrollDis(滚动距离)
b) 下图中条目1、2、3都向上滑到了裁剪区域外,都满足条件,但条目3在这边不回收,主要原因:防止与生成冲突,即:刚生产就被判定为可回收,
导致生成->回收->生成这样不断重复的情况。

伪码
private void CheckAndRecycleTopItems(float scrollDis) {
//先找到从哪条开始往前回收 int recycleItemNodeIndex = -1; for (int i = 0; i < m_ItemNodeList.Count; ++i) { var itemNode = m_ItemNodeList[i]; var itemPosAndSize = m_ItemPosAndSizeList[itemNode.itemIndex]; if (itemPosAndSize.bottomDis < scrollDis) { //该条目到裁剪区域外了 } else { //遇到第1个在裁剪区域内的条目 recycleItemNodeIndex = i - 2; break; } } if (recycleItemNodeIndex >= 0) { for (int i = recycleItemNodeIndex; i >= 0; --i) { var tmpItemNode = m_ItemNodeList[i]; m_ItemNodeList.RemoveAt(i); RecycleItem(tmpItemNode.rtf); } } }
2-2) 底部即将滑入裁剪区域的条目从缓冲池获取后生成
a) 如何判断底部即将滑入裁剪区域?条目的topDis <= (scrollDis + viewportSize)
b) 下图中,条目9的topDis从底部滑入裁剪区域时,表示他后面的条目10即将滑入裁剪区域,此时要从缓存池获取并生成条目10

伪码
private void CheckAndFillBottomItems(float viewportBottomDis) { var listViewContent = m_ScrollRect.content; var lastItemNode = m_ItemNodeList[m_ItemNodeList.Count - 1]; var lastItemPosAndSize = m_ItemPosAndSizeList[lastItemNode.itemIndex]; float tmpTopDis = lastItemPosAndSize.TopDis; float tmpBottomDis = 0; float prevItemSize = lastItemPosAndSize.size; int tmpItemIndex = lastItemNode.itemIndex; while (tmpTopDis <= viewportBottomDis) { tmpItemIndex++; if (tmpItemIndex >= m_DataCount) break; var newItemRtf = m_OnObtainItem(this, tmpItemIndex); if (null == newItemRtf) break; newItemRtf.gameObject.SetActive(true); newItemRtf.SetParent(listViewContent, false); newItemRtf.SetAsLastSibling(); m_ItemNodeList.Add(new ItemNode(newItemRtf, tmpItemIndex)); tmpTopDis += prevItemSize; tmpTopDis += m_ItemSpace; prevItemSize = newItemRtf.rect.height; tmpBottomDis = tmpTopDis + prevItemSize; m_ItemPosAndSizeList[tmpItemIndex] = new ItemPosAndSize(prevItemSize, tmpBottomDis); } }
3) 向下滑动时
3-1) 底部滑到裁剪区域外的条目回收放入缓冲池
a) 如何判断底部滑到裁剪区域外? 条目的topDis > (scrollDis + viewportSize)
b) 下图中条目8、9、10都往下滑到了裁剪区域外,都满足条件,但条目8在这边不回收,主要原因:防止与生成冲突,即:刚生产就被判定为可回收,
导致生成->回收->生成这样不断重复的情况。

伪码
private void CheckAndRecycleBottomItems(float viewportBottomDis) {
//先找到从哪条开始往后回收 int recycleItemNodeIndex = -1; int endIndex = m_ItemNodeList.Count - 1; for (int i = endIndex; i >= 0; --i) { var itemNode = m_ItemNodeList[i]; var itemPosAndSize = m_ItemPosAndSizeList[itemNode.itemIndex]; float topDis = itemPosAndSize.TopDis; if (topDis > viewportBottomDis) { //该条目向下滑动到裁剪区域外了 } else { //遇到第1个在裁剪区域内的条目 recycleItemNodeIndex = i + 2; break; } } if (recycleItemNodeIndex >= 0 && recycleItemNodeIndex <= endIndex) { for (int i = endIndex; i >= recycleItemNodeIndex; --i) { var tmpItemNode = m_ItemNodeList[i]; m_ItemNodeList.RemoveAt(i); RecycleItem(tmpItemNode.rtf); } } }
3-2) 顶部即将滑入裁剪区域的条目从缓冲池获取后生成。
a) 如何判断顶部即将滑入裁剪区域?条目bottomDis >= scrollDis
b) 下图中,条目2的bottomDis从顶部滑入裁剪区域时,表示他前面的条目1即将滑入裁剪区域,此时要从缓存池获取并生成条目1

伪码
private void CheckAndFillTopItems(float scrollDis) { var listViewContent = m_ScrollRect.content; var firstItemNode = m_ItemNodeList[0]; var firstItemPosAndSize = m_ItemPosAndSizeList[firstItemNode.itemIndex]; float prevItemSize = firstItemPosAndSize.size; float tmpTopDis = firstItemPosAndSize.TopDis; float tmpBottomDis = firstItemPosAndSize.bottomDis; int tmpItemIndex = firstItemNode.itemIndex; while (tmpItemIndex > 0 && tmpBottomDis >= scrollDis) { tmpItemIndex -= 1; var newItemRtf = m_OnObtainItem(this, tmpItemIndex); if (null == newItemRtf) break; newItemRtf.gameObject.SetActive(true); newItemRtf.SetParent(listViewContent, false); newItemRtf.SetAsFirstSibling(); m_ItemNodeList.Insert(0, new ItemNode(newItemRtf, tmpItemIndex)); tmpBottomDis -= prevItemSize; tmpBottomDis -= m_ItemSpace; prevItemSize = newItemRtf.rect.height; m_ItemPosAndSizeList[tmpItemIndex] = new ItemPosAndSize(prevItemSize, tmpBottomDis); tmpTopDis = tmpBottomDis - prevItemSize; } }
还要解决的问题
1) 这边content会使用LayoutGroup和ContentSizeFitter组件,他们会有自动布局,上面的几种情况会遇到以下问题:

a) 向上滑动,顶部条目回收后,content的大小会变小,同时所有条目会上移。解决办法:
在顶部加一个占位节点startStub,回收掉后,让占位节点占据掉回收节点的空间。2-1)中就是把占位节点高度设为:条目2的bottomDis
b) 向下滑动,底部条目回收后,content的大小会变小,此时条目不上移。解决办法:
在底部增加一个占位节点endStub,回收掉后,让占位节点占据掉回收节点的空间。3-1)中就是把占位节点的高度设置为:contentSize - 条目9的topDis
c) 向上滑动,底部生成条目后,content的大小会变大,此时需要修正endStub的大小
2-2)中要把占位节点endStub的高度设置为:contantSize - 条目10的bottomDis - 条目间隔
d) 向下滑动,顶部生成条目后,content的大小会变大,所有条目会下移,此时要修正startStub的大小
3-2)中要把顶部占位节点startStub的高度设置为:条目1的topDis - 条目间隔
2) 使用ugui的自动布局,还有一个问题是,大小和位置不是立即设置,而是在Update中统一设置,
这个会造成获取到条目的topDis, bottomDis或itemSize这种不对,这个怎么解决?
a) 有回收或生成条目时,加个标记要在LateUpate中再获取一遍条目的topDis, bottomDis, itemSize这些做修正。
b) 外部修改内容导致了自动布局的触发,我们设置标记要在LateUpdate中做修正。
public void UpdateItemsPosAndSizeNextFrame() { m_LateUpdateFlag = 1; } public void UpdateItemsPosAndSize() { int endIndex = m_ItemNodeList.Count - 1; if (endIndex < 0) return; foreach (var itemNode in m_ItemNodeList) { float itemSize = itemNode.rtf.rect.height; float itemBottomDis = GetItemNodeTopDis(itemNode.rtf) + itemSize; m_ItemPosAndSizeList[itemNode.itemIndex] = new ItemPosAndSize(itemSize, itemBottomDis); } var firstItemNode = m_ItemNodeList[0]; int firstItemIndex = firstItemNode.itemIndex; var firstItemPosAndSize = m_ItemPosAndSizeList[firstItemIndex]; if (firstItemIndex <= 0) { SetStubSize(m_BeginStub, 0); } else { float newSize = firstItemPosAndSize.TopDis - m_ItemSpace; SetStubSize(m_BeginStub, newSize); } //再更新后续的条目 var lastItemNode = m_ItemNodeList[endIndex]; var lastItemPosAndSize = m_ItemPosAndSizeList[lastItemNode.itemIndex]; float tmpBottomDis = lastItemPosAndSize.bottomDis; for (int i = lastItemNode.itemIndex + 1; i < m_DataCount; ++i) { var itemPosAndSize = m_ItemPosAndSizeList[i]; tmpBottomDis += m_ItemSpace; tmpBottomDis += itemPosAndSize.size; itemPosAndSize.bottomDis = tmpBottomDis; m_ItemPosAndSizeList[i] = itemPosAndSize; } float contentSize = tmpBottomDis; SetStubSize(m_EndStub, contentSize - (lastItemPosAndSize.bottomDis + m_ItemSpace)); } private void LateUpdate() { if (m_LateUpdateFlag > 0) { m_LateUpdateFlag--; if (0 == m_LateUpdateFlag) { UpdateItemsPosAndSize(); } } }
//设置占位节点大小 private static void SetStubSize(RectTransform stubRtf, float newSize) { Vector2 sizeDelta = stubRtf.sizeDelta; float oldSize = sizeDelta.y; if (oldSize <= 0.001f) { if (newSize > 0.001f) { stubRtf.gameObject.SetActive(true); sizeDelta.y = newSize; stubRtf.sizeDelta = sizeDelta; } } else { sizeDelta.y = newSize; stubRtf.sizeDelta = sizeDelta; if (newSize <= 0.001f) stubRtf.gameObject.SetActive(false); } }
3) GetItemNodeTopDis的计算
可以通过计算节点左上角相对父节点左上角的坐标来获得

土黄色向量表示的是,节点左上角相对父节点左下角的坐标:参考这边:RectTransform详解
rtf.localPosition + rtf.rect.min + parentRtf.rect.size * parentRtf.pivot + new Vector2(0, rtf.rect.size.y)
红色向量:new Vector2(0, -parentRtf.rect.size.y)
粉色向量:vec2_Orange + vec2_Red
private static float GetItemNodeTopDis(RectTransform rtf) { Rect rect = rtf.rect; Vector2 size = rect.size; var parentRtf = (RectTransform)rtf.parent; Vector2 parentSize = parentRtf.rect.size; Vector2 vec2 = rtf.localPosition; vec2 += rect.min + parentSize * parentRtf.pivot + new Vector2(0, size.y); vec2 += new Vector2(0, -parentSize.y); return -vec2.y; }
实际使用中需要提供的Api
DataCount: 要展示的数据数量
RefreshItems: 根据数据数量和裁剪区域,重新生成所需的条目
ContentSize: 所有数据条目对应UI的总大小
ViewportSize: 裁剪区域大小
ScrollDistance: 滚动距离
ItemNodeCount: 实际生成的条目UI数量
GetItemNodeAt: 获取实际生成的条目UI
ScrollToItem(int itemIndex, float offset): 瞬时滚动到某个条目,常用于滚动到默认选中条目
AnimScrollToItem: 动画滚动到某个条目
ItemObtainDelegate: 获取条目UI
ItemRecycleDelegate: 回收条目UI
ScrollPercent: 当前的滚动条百分比, 值为0~100

浙公网安备 33010602011771号