UGUI扩展 - ListView 循环列表

运行效果

GIF 2025-8-24 11-32-06

 

实现原理

1) 只生成裁剪区域所需的条目

2) 向上滑动时,顶部滑到裁剪区域外的条目回收放入缓冲池,底部即将滑入裁剪区域的条目从缓冲池获取后生成。

3) 向下滑动时,底部滑到裁剪区域外的条目回收放入缓冲池,顶部即将滑入裁剪区域的条目从缓冲池获取后生成。

 

原理图解

1) 只生成裁剪区域所需的条目

  a) 黄色区域为裁剪区域(为了演示关闭了裁剪功能),裁剪区域也叫viewport(视口)

  b) 条目5不在裁剪区域内,为啥也生成了?实际中为了防止穿帮,一般会多生成一条。

image

伪码

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在这边不回收,主要原因:防止与生成冲突,即:刚生产就被判定为可回收,

      导致生成->回收->生成这样不断重复的情况。

image

伪码

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

 image

伪码

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在这边不回收,主要原因:防止与生成冲突,即:刚生产就被判定为可回收,

      导致生成->回收->生成这样不断重复的情况。

image

伪码

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

 image

伪码

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组件,他们会有自动布局,上面的几种情况会遇到以下问题:

image

  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的计算

可以通过计算节点左上角相对父节点左上角的坐标来获得

image

土黄色向量表示的是,节点左上角相对父节点左下角的坐标:参考这边: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

 

posted @ 2025-08-23 23:25  yanghui01  阅读(14)  评论(0)    收藏  举报