Unity中实现无限滑动的ScrollView

当邮件中有1000封邮件,商店列表中有1000个物体,如果直接实例化1000条数据显示则会大大增加 DrawCall,而大量不可见的数据被 Mask 组件排除在可视范围之外,但他们依然存在,这时就需要考虑通过一个无限滑动的 ScrollView 来优化渲染性能,下面这种复用方式提供了一个通用的思路来处理此类问题。

效果展示

实现思路

通过头下标和尾下标记录当前实例化数据的最大最小索引,之后用Content的锚点位置与当头下标的锚点位置进行比较判断滑动的方向以及是否超出滑动范围,如果正方向滑动超出范围则将第一个元素移动到最后一个,如果反方向滑动超出范围则将最后一个元素移动到第一个,这样场景中始终存在5个实例化的元素,依次改变元素的位置和显示即可。

使用说明

  • 此功能脚本是对 ScrollRect 的扩展,所以必须添加 UGUI 提供的基础 Scroll View
  • Content 上必须添加 GridLayoutGroup 组件,通过 GridLayoutGroup 组件设计布局,(我在 代码中对 startCorner、startAxis、childAlignment 和 constraintCount 进行了限制,不需要对其设置)
  • 不能添加 ContentSizeFitter 组件
  • 先调用 SetTotalCount 方法设置总的数据数量再调用 Init 方法进行初始化
  • 根据需求修改 SetShow 方法体
  • 只适用于单向单列滑动的情况,不能满足竖直和水平同时滑动的需求,因为大多数无限滑动列表的使用场景都是单向的
  • 根据实际情况配置固定 item 数量,比如cell宽高、滑动区域的宽高等等
void Start()
{
    // 先设置数量,再初始化
    other.SetTotalCount(200);
    other.Init();
    
    
    // TODO ...
    other.DestoryAll();
}

完整代码

将InfiniteScrollView脚本挂载到ScrollView上

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System.Linq;

/// <summary>
/// 无限滑动列表
/// </summary>
public class InfiniteScrollView : MonoBehaviour
{
    private ScrollRect scrollRect;//滑动框组件
    private RectTransform content;//滑动框的Content
    private GridLayoutGroup layout;//布局组件

    [Header("滑动类型")]
    public ScrollType scrollType;
    [Header("固定的Item数量")]
    public int fixedCount;
    [Header("Item的预制体")]
    public GameObject itemPrefab;

    private int totalCount;//总的数据数量
    private List<RectTransform> dataList = new List<RectTransform>();//数据实体列表
    private int headIndex;//头下标
    private int tailIndex;//尾下标
    private Vector2 firstItemAnchoredPos;//第一个Item的锚点坐标

    #region Init

    /// <summary>
    /// 实例化Item
    /// </summary>
    private void InitItem()
    {
        for (int i = 0; i < fixedCount; i++)
        {
            GameObject tempItem = Instantiate(itemPrefab, content);
            dataList.Add(tempItem.GetComponent<RectTransform>());
            SetShow(tempItem.GetComponent<RectTransform>(), i);
        }
    }

    /// <summary>
    /// 设置Content大小
    /// </summary>
    private void SetContentSize()
    {
        content.sizeDelta = new Vector2
            (
                layout.padding.left + layout.padding.right + totalCount * (layout.cellSize.x + layout.spacing.x) - layout.spacing.x - content.rect.width,
                layout.padding.top + layout.padding.bottom + totalCount * (layout.cellSize.y + layout.spacing.y) - layout.spacing.y
            );
    }

    /// <summary>
    /// 设置布局
    /// </summary>
    private void SetLayout()
    {
        layout.startCorner = GridLayoutGroup.Corner.UpperLeft;
        layout.startAxis = GridLayoutGroup.Axis.Horizontal;
        layout.childAlignment = TextAnchor.UpperLeft;
        layout.constraintCount = 1;
        if (scrollType == ScrollType.Horizontal)
        {
            scrollRect.horizontal = true;
            scrollRect.vertical = false;
            layout.constraint = GridLayoutGroup.Constraint.FixedRowCount;
        }
        else if (scrollType == ScrollType.Vertical)
        {
            scrollRect.horizontal = false;
            scrollRect.vertical = true;
            layout.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
        }
    }

    /// <summary>
    /// 得到第一个数据的锚点位置
    /// </summary>
    private void GetFirstItemAnchoredPos()
    {
        firstItemAnchoredPos = new Vector2
            (
                layout.padding.left + layout.cellSize.x / 2,
                -layout.padding.top - layout.cellSize.y / 2
            );
    }

    #endregion

    #region Main

    /// <summary>
    /// 滑动中
    /// </summary>
    private void OnScroll(Vector2 v)
    {
        if (dataList.Count == 0)
        {
            Debug.LogWarning("先调用SetTotalCount方法设置数据总数量再调用Init方法进行初始化");
            return;
        }

        if (scrollType == ScrollType.Vertical)
        {
            //向上滑
            while (content.anchoredPosition.y >= layout.padding.top + (headIndex + 1) * (layout.cellSize.y + layout.spacing.y)
            && tailIndex != totalCount - 1)
            {
                //将数据列表中的第一个元素移动到最后一个
                RectTransform item = dataList[0];
                dataList.Remove(item);
                dataList.Add(item);

                //设置位置
                SetPos(item, tailIndex + 1);
                //设置显示
                SetShow(item, tailIndex + 1);

                headIndex++;
                tailIndex++;
            }
            //向下滑
            while (content.anchoredPosition.y <= layout.padding.top + headIndex * (layout.cellSize.y + layout.spacing.y)
                && headIndex != 0)
            {
                //将数据列表中的最后一个元素移动到第一个
                RectTransform item = dataList.Last();
                dataList.Remove(item);
                dataList.Insert(0, item);

                //设置位置
                SetPos(item, headIndex - 1);
                //设置显示
                SetShow(item, headIndex - 1);

                headIndex--;
                tailIndex--;
            }
        }
        else if (scrollType == ScrollType.Horizontal)
        {
            //向左滑
            while (content.anchoredPosition.x <= -layout.padding.left - (headIndex + 1) * (layout.cellSize.x + layout.spacing.x)
            && tailIndex != totalCount - 1)
            {
                //将数据列表中的第一个元素移动到最后一个
                RectTransform item = dataList[0];
                dataList.Remove(item);
                dataList.Add(item);

                //设置位置
                SetPos(item, tailIndex + 1);
                //设置显示
                SetShow(item, tailIndex + 1);

                headIndex++;
                tailIndex++;
            }
            //向右滑
            while (content.anchoredPosition.x >= -layout.padding.left - headIndex * (layout.cellSize.x + layout.spacing.x)
            && headIndex != 0)
            {
                //将数据列表中的最后一个元素移动到第一个
                RectTransform item = dataList.Last();
                dataList.Remove(item);
                dataList.Insert(0, item);

                //设置位置
                SetPos(item, headIndex - 1);
                //设置显示
                SetShow(item, headIndex - 1);

                headIndex--;
                tailIndex--;
            }
        }
    }

    #endregion

    #region Tool

    /// <summary>
    /// 设置位置
    /// </summary>
    private void SetPos(RectTransform trans, int index)
    {
        if (scrollType == ScrollType.Horizontal)
        {
            trans.anchoredPosition = new Vector2
            (
                index == 0 ? layout.padding.left + firstItemAnchoredPos.x :
                layout.padding.left + firstItemAnchoredPos.x + index * (layout.cellSize.x + layout.spacing.x),
                firstItemAnchoredPos.y
            );
        }
        else if (scrollType == ScrollType.Vertical)
        {
            trans.anchoredPosition = new Vector2
            (
                firstItemAnchoredPos.x,
                index == 0 ? -layout.padding.top + firstItemAnchoredPos.y :
                -layout.padding.top + firstItemAnchoredPos.y - index * (layout.cellSize.y + layout.spacing.y)
            );
        }
    }

    #endregion

    #region 外部调用

    /// <summary>
    /// 初始化
    /// </summary>
    public void Init()
    {
        scrollRect = GetComponent<ScrollRect>();
        content = scrollRect.content;
        layout = content.GetComponent<GridLayoutGroup>();
        scrollRect.onValueChanged.AddListener((Vector2 v) => OnScroll(v));

        //设置布局
        SetLayout();

        //设置头下标和尾下标
        headIndex = 0;
        tailIndex = fixedCount - 1;

        //设置Content大小
        SetContentSize();

        //实例化Item
        InitItem();

        //得到第一个Item的锚点位置
        GetFirstItemAnchoredPos();
    }

    /// <summary>
    /// 设置显示
    /// </summary>
    public void SetShow(RectTransform trans, int index)
    {
        //=====根据需求进行编写
        trans.GetComponentInChildren<Text>().text = index.ToString();
        trans.name = index.ToString();
    }

    /// <summary>
    /// 设置总的数据数量
    /// </summary>
    public void SetTotalCount(int count)
    {
        totalCount = count;
    }

    /// <summary>
    /// 销毁所有的元素
    /// </summary>
    public void DestoryAll()
    {
        for (int i = dataList.Count - 1; i >= 0; i--)
        {
            DestroyImmediate(dataList[i].gameObject);
        }
        dataList.Clear();
    }

    #endregion
}

/// <summary>
/// 滑动类型
/// </summary>
public enum ScrollType
{
    Horizontal,//竖直滑动
    Vertical,//水平滑动
}

Lua版本

由于实际使用上还是用 Lua,于是根据原作者提供的思路和 c# 代码,翻译成了 Lua脚本:

local class = require("middleclass")
local MyScrollRectView = class("MyScrollRectView")

local ScrollTypeEnum = {
    HORIZONTAL = 0,
    VERTICAL = 1
}

---@param parentTransform CS.UnityEngine.RectTransform 父布局
function MyScrollRectView:initialize(parentTransform)
    --- TODO 包含 ScrollView 的根布局(结构参考Gif)
    ---@type CS.UnityEngine.GameObject
    self.uiRoot = nil    
    self:_initField()
    self.uiRoot.transform:SetParent(parentTransform.transform, false)
end

function MyScrollRectView:_initField()
    --- TODO 要展示的 ItemPrefab
    ---@type CS.UnityEngine.GameObject
    self.itemPrefab = nil

    ---@type CS.UnityEngine.UI.ScrollRect 滑动框组件
    self.scrollRect = self.uiRoot:GetComponent(typeof(CS.UnityEngine.UI.ScrollRect)) 
    
    ---@type CS.UnityEngine.RectTransform 滑动框的Content
    self.content = self.scrollRect.content
    self.content.anchorMax = CS.UnityEngineVector2(0.5, 1)
    self.content.anchorMin = CS.UnityEngineVector2(0.5, 1)
    self.content.pivot =  CS.UnityEngineVector2(0, 1)
    
    ---@type CS.UnityEngine.UI.GridLayoutGroup 布局组件
    self.layout = self.uiRoot.transform:Find("Viewport/Content").gameObject:GetComponent(typeof(CS.UnityEngine.UI.GridLayoutGroup))

    ---@type number 滑动方式 0表示水平 1表示竖直
    self.scrollType = ScrollTypeEnum.VERTICAL

    ---@type number 固定的 Item 数量
    self.fixedCount = 0

    -- 总的数据数量
    self.totalCount = 0
    
    -- 数据实体列表
    self.dataList = {}
    
    -- 头下标
    self.headIndex = 0
    
    -- 尾下标
    self.tailIndex = 0

    -- 第一个Item的锚点坐标
    ---@type CS.UnityEngine.Vector2
    self.firstItemAnchoredPos = CS.UnityEngine.Vector2.zero
end

function MyScrollRectView:_InitItem()
    ---@type CS.UnityEngine.GameObject
    local tempItem
    for i = 0, self.fixedCount - 1 do
        tempItem = CS.UnityEngine.GameObject.Instantiate(self.itemPrefab, self.content)
        table.insert(self.dataList, tempItem:GetComponent(typeof(CS.UnityEngine.RectTransform)))
        self:_SetShow(tempItem:GetComponent(typeof(CS.UnityEngine.RectTransform)), i)
    end
end

function MyScrollRectView:_SetLayout()
    self.layout.startCorner = CS.UnityEngine.UI.GridLayoutGroup.Corner.UpperLeft
    self.layout.startAxis = CS.UnityEngine.UI.GridLayoutGroup.Axis.Horizontal
    self.layout.childAlignment = CS.UnityEngine.TextAnchor.UpperLeft
    self.layout.constraintCount = 1

    -- ScrollType.Horizontal
    if self.scrollType == ScrollTypeEnum.HORIZONTAL then
        self.scrollRect.horizontal = true
        self.scrollRect.vertical = false
        self.layout.constraint = CS.UnityEngine.UI.GridLayoutGroup.Constraint.FixedRowCount
    elseif self.scrollType == ScrollTypeEnum.VERTICAL then
        self.scrollRect.horizontal = false
        self.scrollRect.vertical = true
        self.layout.constraint = CS.UnityEngine.UI.GridLayoutGroup.Constraint.FixedColumnCount
    end
end


function MyScrollRectView:_OnScroll(v)
    if #self.dataList == 0 then
        print("先调用 SetDataLength 方法设置数据总数量,再调用 Init 方法进行初始化")
        return
    end

    if self.scrollType == ScrollTypeEnum.VERTICAL then -- Vertical
        -- 向上滑
        while (self.content.anchoredPosition.y >= (self.layout.padding.top + (self.headIndex + 1) * (self.layout.cellSize.y + self.layout.spacing.y))) and (self.tailIndex ~= self.totalCount - 1) do
            -- 将数据列表中的第一个元素移动到最后一个
            local item = self.dataList[1]
            table.remove(self.dataList, 1)
            table.insert(self.dataList, item)

            -- 设置位置
            self:_SetPos(item, self.tailIndex + 1)

            --设置显示
            self:_SetShow(item, self.tailIndex + 1)

            self.headIndex = self.headIndex + 1
            self.tailIndex = self.tailIndex + 1
        end

        -- 向下滑
        while (self.content.anchoredPosition.y <= self.layout.padding.top + self.headIndex * (self.layout.cellSize.y + self.layout.spacing.y)) and self.headIndex ~= 0 do
            -- 将数据列表中的最后一个元素移动到第一个
            
            local item = self.dataList[#self.dataList]
            table.remove(self.dataList, #self.dataList)
            table.insert(self.dataList, 1, item)

            -- 设置位置
            self:_SetPos(item, self.headIndex - 1)

            --设置显示
            self:_SetShow(item, self.headIndex - 1)

            self.headIndex = self.headIndex - 1
            self.tailIndex = self.tailIndex - 1
        end
    elseif self.scrollType == ScrollTypeEnum.HORIZONTAL then -- 水平滑动
        -- 向左滑
        while (self.content.anchoredPosition.x <= -self.layout.padding.left - (self.headIndex + 1) * (self.layout.cellSize.x + self.layout.spacing.x) and self.tailIndex ~= self.totalCount - 1) do
            --将数据列表中的第一个元素移动到最后一个
            local item = self.dataList[1]
            table.remove(self.dataList, 1)
            table.insert(self.dataList, item)

            --设置位置
            self:_SetPos(item, self.tailIndex + 1)

            --设置显示
            self:_SetShow(item, self.tailIndex + 1)

            self.headIndex = self.headIndex + 1
            self.tailIndex = self.tailIndex + 1
        end
        
        --向右滑
        while (self.content.anchoredPosition.x >= -self.layout.padding.left - self.headIndex * (self.layout.cellSize.x + self.layout.spacing.x) and self.headIndex ~= 0) do        
            --将数据列表中的第一个元素移动到最后一个
            local item = self.dataList[1]
            table.remove(self.dataList, 1)
            table.insert(self.dataList, item)

            --设置位置
            self:_SetPos(item, self.headIndex - 1)

            --设置显示
            self:_SetShow(item, self.headIndex - 1)

            self.headIndex = self.headIndex - 1
            self.tailIndex = self.tailIndex - 1
        end
    end
end

---@param item CS.UnityEngine.GameObject
function MyScrollRectView:_SetPos(item, index)
    local x = 0
    local y = 0
    ---@type CS.UnityEngine.RectTransform
    local rt = item.transform
    if self.scrollType == ScrollTypeEnum.HORIZONTAL then
        x = index == 0 and (self.layout.padding.left + self.firstItemAnchoredPos.x) or (self.layout.padding.left + self.firstItemAnchoredPos.x + index * (self.layout.cellSize.x + self.layout.spacing.x))
        y = self.firstItemAnchoredPos.y
        rt.anchoredPosition = CS.UnityEngine.Vector2(x, y)
    elseif self.scrollType == ScrollTypeEnum.VERTICAL then
        x = self.firstItemAnchoredPos.x
        y = index == 0 and (-self.layout.padding.top + self.firstItemAnchoredPos.y) or (-self.layout.padding.top + self.firstItemAnchoredPos.y - index * (self.layout.cellSize.y + self.layout.spacing.y))
        rt.anchoredPosition = CS.UnityEngine.Vector2(x, y)
    end
end

function MyScrollRectView:_SetContentSize()
    if self.scrollType == ScrollTypeEnum.VERTICAL then
        local x = self.layout.padding.left + self.layout.padding.right + self.layout.cellSize.x + self.layout.spacing.x
        local y = self.layout.padding.top + self.layout.padding.bottom + self.totalCount * (self.layout.cellSize.y + self.layout.spacing.y) - self.layout.spacing.y
        self.content.sizeDelta = CS.UnityEngine.Vector2(x, y)
    elseif self.scrollType == ScrollTypeEnum.HORIZONTAL then
        local x = self.layout.padding.left + self.layout.padding.right + self.layout.cellSize.x + self.layout.spacing.x
        local y = self.layout.padding.top + self.layout.padding.bottom + self.totalCount * (self.layout.cellSize.y + self.layout.spacing.y) - self.layout.spacing.y
        self.content.sizeDelta = CS.UnityEngine.Vector2(x, y)
    end
end

function MyScrollRectView:_GetFirstItemAnchoredPos()
    self.firstItemAnchoredPos = CS.UnityEngine.Vector2(self.layout.padding.left + self.layout.cellSize.x / 2, -self.layout.padding.top - self.layout.cellSize.y / 2)
end

--- #外部调用

function MyScrollRectView:SetDataLength(length)
    --- 设置总数据量
    self.totalCount = length
    self:Init()
end

function MyScrollRectView:Init()
    if self.totalCount <= 0 then
        return
    elseif self.totalCount < 10 then
        self.fixedCount = self.totalCount
    else
        self.fixedCount = 10
    end

    if self.scrollRect["onValueChanged"] ~= nil then
        self.scrollRect["onValueChanged"]:RemoveAllListeners()
    end

    self.commonService:AddEventListener(self.scrollRect, "onValueChanged", function(vector2)
        self:_OnScroll(vector2)
    end)

    --设置布局
    self:_SetLayout()

    --设置头下标和尾下标
    self.headIndex = 0
    self.tailIndex = self.fixedCount - 1

    -- 设置Content大小
    self:_SetContentSize()

    --实例化Item
    self:_InitItem()

    --得到第一个Item的锚点位置
    self:_GetFirstItemAnchoredPos()

    --- Content位置复位
    self.content.anchoredPosition = CS.UnityEngine.Vector2(self.content.anchoredPosition.x, 0)
end

function MyScrollRectView:DestoryAll()
    -- print("执行DestoryAll, #self.dataList = ", #self.dataList)
    for i = #self.dataList, 0, -1 do
        if self.dataList[i] then
            -- print("销毁: ", self.dataList[i].gameObject)
            CS.UnityEngine.GameObject.Destroy(self.dataList[i].gameObject)
        end
    end
    self.dataList = {}
end

--- #外部调用
---
--- 关闭的时销毁 GameObject
function MyScrollRectView:Close()
    self:DestoryAll()
end

--- 属于业务相关方法
---@param i number index
---@param trans CS.UnityEngine.RectTransform
function MyScrollRectView:_SetShow(trans, i)
    --- TODO Item如何展示 example code
    trans.name = i .. ""
    ---@type CS.UnityEngine.UI.RawImage
    local raw = trans.gameObject:GetComponent("RawImage")
    raw.color = CS.UnityEngine.Color(1, 1, 1, 0)
end

return MyScrollRectView

所以核心思想就是根据划进划出把头部的 GameObject 添加到尾部或者把尾部 GameObject 添加到头部!

数据量小/固定时的简单复用

如果数据量很小而且Item数量比较固定的时候直接简单复用即可:

---列表item复用
items = {}

---show function
for i = 1, #dataList do
    ---@type CS.UnityEngine.GameObject
    local itemRootUI = nil

    if items[i] then
        itemRootUI = items[i]
    else
        itemRootUI = GameObject.Instantiate(itemPrefab, contentRectTransform, false)
        table.insert(items, itemRootUI)
    end

    itemRootUI:SetActive(true)
    --- TODO show...
end

Reference

《Unity中实现无限滑动的ScrollView》