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宽高、滑动区域的宽高等等
 1void Start()
 2{
 3    // 先设置数量,再初始化
 4    other.SetTotalCount(200);
 5    other.Init();
 6    
 7    
 8    // TODO ...
 9    other.DestoryAll();
10}

完整代码

将InfiniteScrollView脚本挂载到ScrollView上

  1using System.Collections.Generic;
  2using UnityEngine;
  3using UnityEngine.UI;
  4using System.Linq;
  5
  6/// <summary>
  7/// 无限滑动列表
  8/// </summary>
  9public class InfiniteScrollView : MonoBehaviour
 10{
 11    private ScrollRect scrollRect;//滑动框组件
 12    private RectTransform content;//滑动框的Content
 13    private GridLayoutGroup layout;//布局组件
 14
 15    [Header("滑动类型")]
 16    public ScrollType scrollType;
 17    [Header("固定的Item数量")]
 18    public int fixedCount;
 19    [Header("Item的预制体")]
 20    public GameObject itemPrefab;
 21
 22    private int totalCount;//总的数据数量
 23    private List<RectTransform> dataList = new List<RectTransform>();//数据实体列表
 24    private int headIndex;//头下标
 25    private int tailIndex;//尾下标
 26    private Vector2 firstItemAnchoredPos;//第一个Item的锚点坐标
 27
 28    #region Init
 29
 30    /// <summary>
 31    /// 实例化Item
 32    /// </summary>
 33    private void InitItem()
 34    {
 35        for (int i = 0; i < fixedCount; i++)
 36        {
 37            GameObject tempItem = Instantiate(itemPrefab, content);
 38            dataList.Add(tempItem.GetComponent<RectTransform>());
 39            SetShow(tempItem.GetComponent<RectTransform>(), i);
 40        }
 41    }
 42
 43    /// <summary>
 44    /// 设置Content大小
 45    /// </summary>
 46    private void SetContentSize()
 47    {
 48        content.sizeDelta = new Vector2
 49            (
 50                layout.padding.left + layout.padding.right + totalCount * (layout.cellSize.x + layout.spacing.x) - layout.spacing.x - content.rect.width,
 51                layout.padding.top + layout.padding.bottom + totalCount * (layout.cellSize.y + layout.spacing.y) - layout.spacing.y
 52            );
 53    }
 54
 55    /// <summary>
 56    /// 设置布局
 57    /// </summary>
 58    private void SetLayout()
 59    {
 60        layout.startCorner = GridLayoutGroup.Corner.UpperLeft;
 61        layout.startAxis = GridLayoutGroup.Axis.Horizontal;
 62        layout.childAlignment = TextAnchor.UpperLeft;
 63        layout.constraintCount = 1;
 64        if (scrollType == ScrollType.Horizontal)
 65        {
 66            scrollRect.horizontal = true;
 67            scrollRect.vertical = false;
 68            layout.constraint = GridLayoutGroup.Constraint.FixedRowCount;
 69        }
 70        else if (scrollType == ScrollType.Vertical)
 71        {
 72            scrollRect.horizontal = false;
 73            scrollRect.vertical = true;
 74            layout.constraint = GridLayoutGroup.Constraint.FixedColumnCount;
 75        }
 76    }
 77
 78    /// <summary>
 79    /// 得到第一个数据的锚点位置
 80    /// </summary>
 81    private void GetFirstItemAnchoredPos()
 82    {
 83        firstItemAnchoredPos = new Vector2
 84            (
 85                layout.padding.left + layout.cellSize.x / 2,
 86                -layout.padding.top - layout.cellSize.y / 2
 87            );
 88    }
 89
 90    #endregion
 91
 92    #region Main
 93
 94    /// <summary>
 95    /// 滑动中
 96    /// </summary>
 97    private void OnScroll(Vector2 v)
 98    {
 99        if (dataList.Count == 0)
100        {
101            Debug.LogWarning("先调用SetTotalCount方法设置数据总数量再调用Init方法进行初始化");
102            return;
103        }
104
105        if (scrollType == ScrollType.Vertical)
106        {
107            //向上滑
108            while (content.anchoredPosition.y >= layout.padding.top + (headIndex + 1) * (layout.cellSize.y + layout.spacing.y)
109            && tailIndex != totalCount - 1)
110            {
111                //将数据列表中的第一个元素移动到最后一个
112                RectTransform item = dataList[0];
113                dataList.Remove(item);
114                dataList.Add(item);
115
116                //设置位置
117                SetPos(item, tailIndex + 1);
118                //设置显示
119                SetShow(item, tailIndex + 1);
120
121                headIndex++;
122                tailIndex++;
123            }
124            //向下滑
125            while (content.anchoredPosition.y <= layout.padding.top + headIndex * (layout.cellSize.y + layout.spacing.y)
126                && headIndex != 0)
127            {
128                //将数据列表中的最后一个元素移动到第一个
129                RectTransform item = dataList.Last();
130                dataList.Remove(item);
131                dataList.Insert(0, item);
132
133                //设置位置
134                SetPos(item, headIndex - 1);
135                //设置显示
136                SetShow(item, headIndex - 1);
137
138                headIndex--;
139                tailIndex--;
140            }
141        }
142        else if (scrollType == ScrollType.Horizontal)
143        {
144            //向左滑
145            while (content.anchoredPosition.x <= -layout.padding.left - (headIndex + 1) * (layout.cellSize.x + layout.spacing.x)
146            && tailIndex != totalCount - 1)
147            {
148                //将数据列表中的第一个元素移动到最后一个
149                RectTransform item = dataList[0];
150                dataList.Remove(item);
151                dataList.Add(item);
152
153                //设置位置
154                SetPos(item, tailIndex + 1);
155                //设置显示
156                SetShow(item, tailIndex + 1);
157
158                headIndex++;
159                tailIndex++;
160            }
161            //向右滑
162            while (content.anchoredPosition.x >= -layout.padding.left - headIndex * (layout.cellSize.x + layout.spacing.x)
163            && headIndex != 0)
164            {
165                //将数据列表中的最后一个元素移动到第一个
166                RectTransform item = dataList.Last();
167                dataList.Remove(item);
168                dataList.Insert(0, item);
169
170                //设置位置
171                SetPos(item, headIndex - 1);
172                //设置显示
173                SetShow(item, headIndex - 1);
174
175                headIndex--;
176                tailIndex--;
177            }
178        }
179    }
180
181    #endregion
182
183    #region Tool
184
185    /// <summary>
186    /// 设置位置
187    /// </summary>
188    private void SetPos(RectTransform trans, int index)
189    {
190        if (scrollType == ScrollType.Horizontal)
191        {
192            trans.anchoredPosition = new Vector2
193            (
194                index == 0 ? layout.padding.left + firstItemAnchoredPos.x :
195                layout.padding.left + firstItemAnchoredPos.x + index * (layout.cellSize.x + layout.spacing.x),
196                firstItemAnchoredPos.y
197            );
198        }
199        else if (scrollType == ScrollType.Vertical)
200        {
201            trans.anchoredPosition = new Vector2
202            (
203                firstItemAnchoredPos.x,
204                index == 0 ? -layout.padding.top + firstItemAnchoredPos.y :
205                -layout.padding.top + firstItemAnchoredPos.y - index * (layout.cellSize.y + layout.spacing.y)
206            );
207        }
208    }
209
210    #endregion
211
212    #region 外部调用
213
214    /// <summary>
215    /// 初始化
216    /// </summary>
217    public void Init()
218    {
219        scrollRect = GetComponent<ScrollRect>();
220        content = scrollRect.content;
221        layout = content.GetComponent<GridLayoutGroup>();
222        scrollRect.onValueChanged.AddListener((Vector2 v) => OnScroll(v));
223
224        //设置布局
225        SetLayout();
226
227        //设置头下标和尾下标
228        headIndex = 0;
229        tailIndex = fixedCount - 1;
230
231        //设置Content大小
232        SetContentSize();
233
234        //实例化Item
235        InitItem();
236
237        //得到第一个Item的锚点位置
238        GetFirstItemAnchoredPos();
239    }
240
241    /// <summary>
242    /// 设置显示
243    /// </summary>
244    public void SetShow(RectTransform trans, int index)
245    {
246        //=====根据需求进行编写
247        trans.GetComponentInChildren<Text>().text = index.ToString();
248        trans.name = index.ToString();
249    }
250
251    /// <summary>
252    /// 设置总的数据数量
253    /// </summary>
254    public void SetTotalCount(int count)
255    {
256        totalCount = count;
257    }
258
259    /// <summary>
260    /// 销毁所有的元素
261    /// </summary>
262    public void DestoryAll()
263    {
264        for (int i = dataList.Count - 1; i >= 0; i--)
265        {
266            DestroyImmediate(dataList[i].gameObject);
267        }
268        dataList.Clear();
269    }
270
271    #endregion
272}
273
274/// <summary>
275/// 滑动类型
276/// </summary>
277public enum ScrollType
278{
279    Horizontal,//竖直滑动
280    Vertical,//水平滑动
281}

Lua版本

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

  1local class = require("middleclass")
  2local MyScrollRectView = class("MyScrollRectView")
  3
  4local ScrollTypeEnum = {
  5    HORIZONTAL = 0,
  6    VERTICAL = 1
  7}
  8
  9---@param parentTransform CS.UnityEngine.RectTransform 父布局
 10function MyScrollRectView:initialize(parentTransform)
 11    --- TODO 包含 ScrollView 的根布局(结构参考Gif)
 12    ---@type CS.UnityEngine.GameObject
 13    self.uiRoot = nil    
 14    self:_initField()
 15    self.uiRoot.transform:SetParent(parentTransform.transform, false)
 16end
 17
 18function MyScrollRectView:_initField()
 19    --- TODO 要展示的 ItemPrefab
 20    ---@type CS.UnityEngine.GameObject
 21    self.itemPrefab = nil
 22
 23    ---@type CS.UnityEngine.UI.ScrollRect 滑动框组件
 24    self.scrollRect = self.uiRoot:GetComponent(typeof(CS.UnityEngine.UI.ScrollRect)) 
 25    
 26    ---@type CS.UnityEngine.RectTransform 滑动框的Content
 27    self.content = self.scrollRect.content
 28    self.content.anchorMax = CS.UnityEngineVector2(0.5, 1)
 29    self.content.anchorMin = CS.UnityEngineVector2(0.5, 1)
 30    self.content.pivot =  CS.UnityEngineVector2(0, 1)
 31    
 32    ---@type CS.UnityEngine.UI.GridLayoutGroup 布局组件
 33    self.layout = self.uiRoot.transform:Find("Viewport/Content").gameObject:GetComponent(typeof(CS.UnityEngine.UI.GridLayoutGroup))
 34
 35    ---@type number 滑动方式 0表示水平 1表示竖直
 36    self.scrollType = ScrollTypeEnum.VERTICAL
 37
 38    ---@type number 固定的 Item 数量
 39    self.fixedCount = 0
 40
 41    -- 总的数据数量
 42    self.totalCount = 0
 43    
 44    -- 数据实体列表
 45    self.dataList = {}
 46    
 47    -- 头下标
 48    self.headIndex = 0
 49    
 50    -- 尾下标
 51    self.tailIndex = 0
 52
 53    -- 第一个Item的锚点坐标
 54    ---@type CS.UnityEngine.Vector2
 55    self.firstItemAnchoredPos = CS.UnityEngine.Vector2.zero
 56end
 57
 58function MyScrollRectView:_InitItem()
 59    ---@type CS.UnityEngine.GameObject
 60    local tempItem
 61    for i = 0, self.fixedCount - 1 do
 62        tempItem = CS.UnityEngine.GameObject.Instantiate(self.itemPrefab, self.content)
 63        table.insert(self.dataList, tempItem:GetComponent(typeof(CS.UnityEngine.RectTransform)))
 64        self:_SetShow(tempItem:GetComponent(typeof(CS.UnityEngine.RectTransform)), i)
 65    end
 66end
 67
 68function MyScrollRectView:_SetLayout()
 69    self.layout.startCorner = CS.UnityEngine.UI.GridLayoutGroup.Corner.UpperLeft
 70    self.layout.startAxis = CS.UnityEngine.UI.GridLayoutGroup.Axis.Horizontal
 71    self.layout.childAlignment = CS.UnityEngine.TextAnchor.UpperLeft
 72    self.layout.constraintCount = 1
 73
 74    -- ScrollType.Horizontal
 75    if self.scrollType == ScrollTypeEnum.HORIZONTAL then
 76        self.scrollRect.horizontal = true
 77        self.scrollRect.vertical = false
 78        self.layout.constraint = CS.UnityEngine.UI.GridLayoutGroup.Constraint.FixedRowCount
 79    elseif self.scrollType == ScrollTypeEnum.VERTICAL then
 80        self.scrollRect.horizontal = false
 81        self.scrollRect.vertical = true
 82        self.layout.constraint = CS.UnityEngine.UI.GridLayoutGroup.Constraint.FixedColumnCount
 83    end
 84end
 85
 86
 87function MyScrollRectView:_OnScroll(v)
 88    if #self.dataList == 0 then
 89        print("先调用 SetDataLength 方法设置数据总数量,再调用 Init 方法进行初始化")
 90        return
 91    end
 92
 93    if self.scrollType == ScrollTypeEnum.VERTICAL then -- Vertical
 94        -- 向上滑
 95        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
 96            -- 将数据列表中的第一个元素移动到最后一个
 97            local item = self.dataList[1]
 98            table.remove(self.dataList, 1)
 99            table.insert(self.dataList, item)
100
101            -- 设置位置
102            self:_SetPos(item, self.tailIndex + 1)
103
104            --设置显示
105            self:_SetShow(item, self.tailIndex + 1)
106
107            self.headIndex = self.headIndex + 1
108            self.tailIndex = self.tailIndex + 1
109        end
110
111        -- 向下滑
112        while (self.content.anchoredPosition.y <= self.layout.padding.top + self.headIndex * (self.layout.cellSize.y + self.layout.spacing.y)) and self.headIndex ~= 0 do
113            -- 将数据列表中的最后一个元素移动到第一个
114            
115            local item = self.dataList[#self.dataList]
116            table.remove(self.dataList, #self.dataList)
117            table.insert(self.dataList, 1, item)
118
119            -- 设置位置
120            self:_SetPos(item, self.headIndex - 1)
121
122            --设置显示
123            self:_SetShow(item, self.headIndex - 1)
124
125            self.headIndex = self.headIndex - 1
126            self.tailIndex = self.tailIndex - 1
127        end
128    elseif self.scrollType == ScrollTypeEnum.HORIZONTAL then -- 水平滑动
129        -- 向左滑
130        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
131            --将数据列表中的第一个元素移动到最后一个
132            local item = self.dataList[1]
133            table.remove(self.dataList, 1)
134            table.insert(self.dataList, item)
135
136            --设置位置
137            self:_SetPos(item, self.tailIndex + 1)
138
139            --设置显示
140            self:_SetShow(item, self.tailIndex + 1)
141
142            self.headIndex = self.headIndex + 1
143            self.tailIndex = self.tailIndex + 1
144        end
145        
146        --向右滑
147        while (self.content.anchoredPosition.x >= -self.layout.padding.left - self.headIndex * (self.layout.cellSize.x + self.layout.spacing.x) and self.headIndex ~= 0) do        
148            --将数据列表中的第一个元素移动到最后一个
149            local item = self.dataList[1]
150            table.remove(self.dataList, 1)
151            table.insert(self.dataList, item)
152
153            --设置位置
154            self:_SetPos(item, self.headIndex - 1)
155
156            --设置显示
157            self:_SetShow(item, self.headIndex - 1)
158
159            self.headIndex = self.headIndex - 1
160            self.tailIndex = self.tailIndex - 1
161        end
162    end
163end
164
165---@param item CS.UnityEngine.GameObject
166function MyScrollRectView:_SetPos(item, index)
167    local x = 0
168    local y = 0
169    ---@type CS.UnityEngine.RectTransform
170    local rt = item.transform
171    if self.scrollType == ScrollTypeEnum.HORIZONTAL then
172        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))
173        y = self.firstItemAnchoredPos.y
174        rt.anchoredPosition = CS.UnityEngine.Vector2(x, y)
175    elseif self.scrollType == ScrollTypeEnum.VERTICAL then
176        x = self.firstItemAnchoredPos.x
177        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))
178        rt.anchoredPosition = CS.UnityEngine.Vector2(x, y)
179    end
180end
181
182function MyScrollRectView:_SetContentSize()
183    if self.scrollType == ScrollTypeEnum.VERTICAL then
184        local x = self.layout.padding.left + self.layout.padding.right + self.layout.cellSize.x + self.layout.spacing.x
185        local y = self.layout.padding.top + self.layout.padding.bottom + self.totalCount * (self.layout.cellSize.y + self.layout.spacing.y) - self.layout.spacing.y
186        self.content.sizeDelta = CS.UnityEngine.Vector2(x, y)
187    elseif self.scrollType == ScrollTypeEnum.HORIZONTAL then
188        local x = self.layout.padding.left + self.layout.padding.right + self.layout.cellSize.x + self.layout.spacing.x
189        local y = self.layout.padding.top + self.layout.padding.bottom + self.totalCount * (self.layout.cellSize.y + self.layout.spacing.y) - self.layout.spacing.y
190        self.content.sizeDelta = CS.UnityEngine.Vector2(x, y)
191    end
192end
193
194function MyScrollRectView:_GetFirstItemAnchoredPos()
195    self.firstItemAnchoredPos = CS.UnityEngine.Vector2(self.layout.padding.left + self.layout.cellSize.x / 2, -self.layout.padding.top - self.layout.cellSize.y / 2)
196end
197
198--- #外部调用
199
200function MyScrollRectView:SetDataLength(length)
201    --- 设置总数据量
202    self.totalCount = length
203    self:Init()
204end
205
206function MyScrollRectView:Init()
207    if self.totalCount <= 0 then
208        return
209    elseif self.totalCount < 10 then
210        self.fixedCount = self.totalCount
211    else
212        self.fixedCount = 10
213    end
214
215    if self.scrollRect["onValueChanged"] ~= nil then
216        self.scrollRect["onValueChanged"]:RemoveAllListeners()
217    end
218
219    self.commonService:AddEventListener(self.scrollRect, "onValueChanged", function(vector2)
220        self:_OnScroll(vector2)
221    end)
222
223    --设置布局
224    self:_SetLayout()
225
226    --设置头下标和尾下标
227    self.headIndex = 0
228    self.tailIndex = self.fixedCount - 1
229
230    -- 设置Content大小
231    self:_SetContentSize()
232
233    --实例化Item
234    self:_InitItem()
235
236    --得到第一个Item的锚点位置
237    self:_GetFirstItemAnchoredPos()
238
239    --- Content位置复位
240    self.content.anchoredPosition = CS.UnityEngine.Vector2(self.content.anchoredPosition.x, 0)
241end
242
243function MyScrollRectView:DestoryAll()
244    -- print("执行DestoryAll, #self.dataList = ", #self.dataList)
245    for i = #self.dataList, 0, -1 do
246        if self.dataList[i] then
247            -- print("销毁: ", self.dataList[i].gameObject)
248            CS.UnityEngine.GameObject.Destroy(self.dataList[i].gameObject)
249        end
250    end
251    self.dataList = {}
252end
253
254--- #外部调用
255---
256--- 关闭的时销毁 GameObject
257function MyScrollRectView:Close()
258    self:DestoryAll()
259end
260
261--- 属于业务相关方法
262---@param i number index
263---@param trans CS.UnityEngine.RectTransform
264function MyScrollRectView:_SetShow(trans, i)
265    --- TODO Item如何展示 example code
266    trans.name = i .. ""
267    ---@type CS.UnityEngine.UI.RawImage
268    local raw = trans.gameObject:GetComponent("RawImage")
269    raw.color = CS.UnityEngine.Color(1, 1, 1, 0)
270end
271
272return MyScrollRectView
273

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

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

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

 1---列表item复用
 2items = {}
 3
 4---show function
 5for i = 1, #dataList do
 6    ---@type CS.UnityEngine.GameObject
 7    local itemRootUI = nil
 8
 9    if items[i] then
10        itemRootUI = items[i]
11    else
12        itemRootUI = GameObject.Instantiate(itemPrefab, contentRectTransform, false)
13        table.insert(items, itemRootUI)
14    end
15
16    itemRootUI:SetActive(true)
17    --- TODO show...
18end

Reference

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