Unity 无限滑动列表(RecycleView)
一、功能概述
RecycleView 是一个 基于对象复用(对象池思想)的无限滑动列表组件,用于解决 Unity 中 ScrollView 在大量数据场景下的性能问题。
核心目标:
- 列表数据量再大,也只创建「可视区域所需的最少 Cell 数量」
- 通过滑动动态复用 Cell,而不是反复 Instantiate / Destroy
- 同时支持:
- 垂直 / 水平滑动
- 单列 / 多行(多列)布局
- 动态刷新、局部刷新、定位滚动
- 目录结构说明
InfiniteSlidingList/
├── RecycleView.cs // 无限滑动列表实现
├── RecycleViewTest.cs // 使用示例
Editor/
└── RecycleViewEditor.cs // Inspector 可视化
二、无限滑动列表代码实现(RecycleView.cs)
// 案例详见RecycleViewTest.cs
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
using System;
namespace InfiniteSlidingList
{
public enum E_Direction
{
Horizontal,
Vertical
}
// 定义RecycleView类,继承MonoBehaviour并实现拖拽接口
public class RecycleView : MonoBehaviour
{
// [Header("布局设置")]
public E_Direction dir = E_Direction.Vertical; // 滑动方向,默认为垂直
public int lines = 1; // 每行/列显示的子项数量(多行布局时使用)
public float squareSpacing = 5f; // 统一间距(行列相同时使用)
public Vector2 Spacing = Vector2.zero; // 独立行列间距(x:行间距,y:列间距)
private float row = 0f; // 实际使用的行间距(根据Spacing计算)
private float col = 0f; // 实际使用的列间距(根据Spacing计算)
public float paddingTop = 0f; // 内容区域顶部内边距
public float paddingLeft = 0f; // 内容区域左侧内边距
public GameObject cell; // 子项预制体引用(必须赋值)
// 回调函数定义
protected Action<GameObject, int> FuncCallBackFunc; // 子项数据绑定回调
protected Action<GameObject, int> FuncOnClickCallBack; // 子项点击回调
protected Action<int, bool, GameObject> FuncOnButtonClickCallBack; // 子项按钮回调
// 尺寸记录字段
protected float planeW; // ScrollView可视区域宽度
protected float planeH; // ScrollView可视区域高度
protected float contentW; // Content总宽度
protected float contentH; // Content总高度
protected float cellW; // 子项预制体宽度
protected float cellH; // 子项预制体高度
// 状态标志
private bool isInit = false; // 是否已完成初始化
protected GameObject content; // ScrollRect的Content对象引用
protected ScrollRect scrollRect; // ScrollRect组件缓存
protected RectTransform rectTrans; // 自身RectTransform
protected RectTransform contentRectTrans; // Content的RectTransform
// 列表控制字段
protected int maxCount = -1; // 当前列表总数量(-1表示未初始化)
protected int minIndex = -1; // 当前显示的最小索引
protected int maxIndex = -1; // 当前显示的最大索引
// 子项信息结构体
protected struct CellInfo
{
public Vector3 pos; // 子项本地坐标
public GameObject obj; // 子项实例对象
};
protected CellInfo[] cellInfos; // 存储所有子项信息的数组
protected bool isClearList = false; // 是否强制清空列表标志
// 对象池系统
protected Stack<GameObject> Pool = new Stack<GameObject>(); // 子项对象池
protected bool isInited = false; // 是否完成首次数据加载
//===== 初始化方法 =====//
/// <summary>
/// 简化版初始化(只有数据回调)
/// </summary>
public virtual void Init(Action<GameObject, int> callBack)
{
// 调用完整初始化方法,点击回调设为null
Init(callBack, null);
}
/// <summary>
/// 完整初始化方法(带按钮回调)
/// </summary>
public virtual void Init(Action<GameObject, int> callBack, Action<GameObject, int> onClickCallBack,
Action<int, bool, GameObject> onButtonClickCallBack)
{
// 存储按钮回调(如果存在)
if (onButtonClickCallBack != null)
{
FuncOnButtonClickCallBack = onButtonClickCallBack;
}
// 继续执行标准初始化
Init(callBack, onClickCallBack);
}
/// <summary>
/// 核心初始化逻辑
/// </summary>
public virtual void Init(Action<GameObject, int> callBack, Action<GameObject, int> onClickCallBack)
{
// 清理旧数据(防止重复初始化问题)
DisposeAll();
// 存储数据绑定回调
FuncCallBackFunc = callBack;
// 存储点击回调(如果存在)
if (onClickCallBack != null)
{
FuncOnClickCallBack = onClickCallBack;
}
// 如果已经初始化过,直接返回
if (isInit) return;
// 获取ScrollRect的content对象
content = this.GetComponent<ScrollRect>().content.gameObject;
// 如果未手动指定子项预制体,自动获取Content下第一个子对象
if (cell == null)
{
cell = content.transform.GetChild(0).gameObject;
}
// 将子项模板放入对象池(初始隐藏)
SetPoolsObj(cell);
// 配置子项RectTransform
RectTransform cellRectTrans = cell.GetComponent<RectTransform>();
cellRectTrans.pivot = new Vector2(0f, 1f); // 设置轴心点为左上角
CheckAnchor(cellRectTrans); // 验证锚点设置
//cellRectTrans.anchoredPosition = Vector2.zero; // 重置位置(相对与锚点的位置,设为0)感觉和上面设置轴心、锚点位置重复
// 记录子项原始尺寸
cellH = cellRectTrans.rect.height;//rect表示物体矩形区域
cellW = cellRectTrans.rect.width;
// 记录ScrollView可视区域尺,这里默认Viewport矩形大小等于ScrollView矩形大小
rectTrans = GetComponent<RectTransform>();
Rect planeRect = rectTrans.rect;
planeH = planeRect.height;
planeW = planeRect.width;
// 记录Content原始尺寸
contentRectTrans = content.GetComponent<RectTransform>();
//Rect contentRect = contentRectTrans.rect;
//contentH = contentRect.height;
//contentW = contentRect.width;//感觉没用
// 计算实际使用的间距值
row = Spacing.x; // 从Vector2获取行间距
col = Spacing.y; // 从Vector2获取列间距
if (row == 0 && col == 0)
{
// 如果未设置独立间距,使用统一间距
row = col = squareSpacing;
}
else
{
// 如果使用独立间距,清空统一间距
squareSpacing = 0;
}
// 配置Content的RectTransform
contentRectTrans.pivot = new Vector2(0f, 1f); // 左上角轴心
CheckAnchor(contentRectTrans); // 验证锚点
// 获取ScrollRect组件并清除旧监听
scrollRect = this.GetComponent<ScrollRect>();
scrollRect.onValueChanged.RemoveAllListeners();
// 添加滑动事件监听
scrollRect.onValueChanged.AddListener(delegate (Vector2 value)
{
ScrollRectListener(value); // 监听滑动位置变化
});
// 标记初始化完成
isInit = true;
}
/// <summary>
/// 检查RectTransform的锚点设置是否符合当前滑动方向要求
/// </summary>
/// <param name="rectTrans">需要检查的RectTransform</param>
private void CheckAnchor(RectTransform rectTrans)
{
// 垂直滑动模式的锚点验证
if (dir == E_Direction.Vertical)
{
// 允许的锚点配置:
// 1. 左上角固定锚点(min(0,1), max(0,1))
// 2. 顶部横向拉伸锚点(min(0,1), max(1,1))
if (!((rectTrans.anchorMin == new Vector2(0, 1) && rectTrans.anchorMax == new Vector2(0, 1)) ||
(rectTrans.anchorMin == new Vector2(0, 1) && rectTrans.anchorMax == new Vector2(1, 1))))
{
// 自动修正为顶部横向拉伸锚点
rectTrans.anchorMin = new Vector2(0, 1);
rectTrans.anchorMax = new Vector2(1, 1);
}
}
// 水平滑动模式的锚点验证
else
{
// 允许的锚点配置:
// 1. 左上角固定锚点(min(0,1), max(0,1))
// 2. 左侧纵向拉伸锚点(min(0,0), max(0,1))
if (!((rectTrans.anchorMin == new Vector2(0, 1) && rectTrans.anchorMax == new Vector2(0, 1)) ||
(rectTrans.anchorMin == new Vector2(0, 0) && rectTrans.anchorMax == new Vector2(0, 1))))
{
// 自动修正为左侧纵向拉伸锚点
rectTrans.anchorMin = new Vector2(0, 0);
rectTrans.anchorMax = new Vector2(0, 1);
}
}
}
/// <summary>
/// 核心列表显示方法
/// </summary>
/// <param name="num">要显示的子项总数</param>
public virtual void ShowList(int num)
{
// 重置显示范围标记
minIndex = -1;
maxIndex = -1;
//========== 计算Content尺寸 ==========//
if (dir == E_Direction.Vertical)
{
// 垂直方向计算:
// 总高度 = (单元格高度+列间距) * 行数 + 顶部内边距
float contentSize = (col + cellH) * Mathf.CeilToInt((float)num / lines) + paddingTop;
contentH = contentSize; // 记录内容高度
contentW = contentRectTrans.sizeDelta.x + paddingLeft; // 宽度保持原有+左边距
// 如果内容高度小于可视区域,则使用可视区域高度
contentSize = contentSize < rectTrans.rect.height ? rectTrans.rect.height : contentSize;
// 应用新尺寸
contentRectTrans.sizeDelta = new Vector2(contentW, contentSize);
// 如果列表数量变化,重置滚动位置到顶部
if (num != maxCount)
{
contentRectTrans.anchoredPosition = new Vector2(contentRectTrans.anchoredPosition.x, 0);
}
}
else
{
// 水平方向计算:
// 总宽度 = (单元格宽度+行间距) * 列数 + 左侧内边距
float contentSize = (row + cellW) * Mathf.CeilToInt((float)num / lines) + paddingLeft;
contentW = contentSize;
contentH = contentRectTrans.sizeDelta.x + paddingLeft;
// 如果内容宽度小于可视区域,则使用可视区域宽度
contentSize = contentSize < rectTrans.rect.width ? rectTrans.rect.width : contentSize;
// 应用新尺寸
contentRectTrans.sizeDelta = new Vector2(contentSize, contentH);
// 如果列表数量变化,重置滚动位置到起始
if (num != maxCount)
{
contentRectTrans.anchoredPosition = new Vector2(0, contentRectTrans.anchoredPosition.y);
}
}
//========== 处理已有子项 ==========//
int lastEndIndex = 0; // 旧数据的有效截止索引
// 如果不是首次加载
if (isInited)
{
// 计算需要保留的旧数据量
lastEndIndex = num - maxCount > 0 ? maxCount : num;
// 如果要求清空列表,则从0开始
lastEndIndex = isClearList ? 0 : lastEndIndex;
// 回收多余子项到对象池
int count = isClearList ? cellInfos.Length : maxCount;
for (int i = lastEndIndex; i < count; i++)
{
if (cellInfos[i].obj != null)
{
SetPoolsObj(cellInfos[i].obj); // 回收到对象池
cellInfos[i].obj = null; // 清空引用
}
}
}
//========== 创建新数据数组 ==========//
CellInfo[] tempCellInfos = cellInfos; // 临时保存旧数据
cellInfos = new CellInfo[num]; // 创建新数组
//========== 计算子项布局 ==========//
for (int i = 0; i < num; i++)
{
//--> 复用已有数据
if (maxCount != -1 && i < lastEndIndex)
{
CellInfo tempCellInfo = tempCellInfos[i];
// 计算子项是否在可见范围内
float rPos = dir == E_Direction.Vertical ? tempCellInfo.pos.y : tempCellInfo.pos.x;
if (!IsOutRange(rPos))
{
// 更新显示范围标记
minIndex = minIndex == -1 ? i : minIndex;
maxIndex = i;
// 如果对象不存在则从池中获取
if (tempCellInfo.obj == null)
{
tempCellInfo.obj = GetPoolsObj();
}
// 修正位置(使用localPosition避免z轴问题)
tempCellInfo.obj.transform.GetComponent<RectTransform>().localPosition = tempCellInfo.pos;
tempCellInfo.obj.name = i.ToString(); // 用索引命名便于调试
tempCellInfo.obj.SetActive(true);
// 执行数据绑定
Func(FuncCallBackFunc, tempCellInfo.obj);
}
else
{
// 不可见的子项回收到对象池
SetPoolsObj(tempCellInfo.obj);
tempCellInfo.obj = null;
}
cellInfos[i] = tempCellInfo;
continue;
}
//--> 创建新子项数据
CellInfo cellInfo = new CellInfo();
float pos = 0; // 主轴方向坐标
float rowPos = 0; // 副轴方向坐标
// 计算子项坐标
if (dir == E_Direction.Vertical)
{
// 垂直布局计算:
// Y坐标 = (单元格高度+列间距) * 行索引
pos = cellH * Mathf.FloorToInt(i / lines) + col * Mathf.FloorToInt(i / lines);
// X坐标 = (单元格宽度+行间距) * 行内索引
rowPos = cellW * (i % lines) + row * (i % lines);
// 最终位置 = (X+左内边距, -Y-顶内边距)
cellInfo.pos = new Vector3(rowPos + paddingLeft, -pos - paddingTop, 0);//相对于左上角锚点位置
}
else
{
// 水平布局计算:
// X坐标 = (单元格宽度+行间距) * 列索引
pos = cellW * Mathf.FloorToInt(i / lines) + row * Mathf.FloorToInt(i / lines);
// Y坐标 = (单元格高度+列间距) * 列内索引
rowPos = cellH * (i % lines) + col * (i % lines);
// 最终位置 = (X+左内边距, -Y-顶内边距)
cellInfo.pos = new Vector3(pos + paddingLeft, -rowPos - paddingTop, 0);//相对于左上角锚点位置
}
// 检查是否在可见范围内
float cellPos = dir == E_Direction.Vertical ? cellInfo.pos.y : cellInfo.pos.x;
if (IsOutRange(cellPos))
{
cellInfo.obj = null;
cellInfos[i] = cellInfo;
continue;
}
// 更新显示范围标记
minIndex = minIndex == -1 ? i : minIndex;
maxIndex = i;
// 从对象池获取子项实例
GameObject cellObj = GetPoolsObj();
cellObj.transform.GetComponent<RectTransform>().localPosition = cellInfo.pos;
cellObj.name = i.ToString();
// 存储到数组
cellInfo.obj = cellObj;
cellInfos[i] = cellInfo;
// 执行数据绑定回调
Func(FuncCallBackFunc, cellObj);
}
// 更新列表状态
maxCount = num;
isInited = true;
}
/// <summary>
/// 滑动事件监听回调
/// </summary>
/// <param name="value">当前滑动位置归一化坐标</param>
protected virtual void ScrollRectListener(Vector2 value)
{
// 触发可见性检查
UpdateCheck();
}
/// <summary>
/// 更新所有子项的可见状态
/// </summary>
private void UpdateCheck()
{
// 安全检查
if (cellInfos == null) return;
// 遍历所有子项
for (int i = 0, length = cellInfos.Length; i < length; i++)
{
CellInfo cellInfo = cellInfos[i];
GameObject obj = cellInfo.obj;
Vector3 pos = cellInfo.pos;
// 根据滑动方向获取关键坐标
float rangePos = dir == E_Direction.Vertical ? pos.y : pos.x;
// 判断是否超出显示范围
if (IsOutRange(rangePos))
{
// 回收不可见的子项
if (obj != null)
{
SetPoolsObj(obj);
cellInfos[i].obj = null;
}
}
else
{
// 处理需要显示的子项
if (obj == null)
{
// 从对象池获取或创建新子项
GameObject cell = GetPoolsObj();
// 设置位置和名称
cell.transform.localPosition = pos;
cell.gameObject.name = i.ToString();
// 更新引用
cellInfos[i].obj = cell;
// 执行数据绑定回调
Func(FuncCallBackFunc, cell);
}
}
}
}
/// <summary>
/// 判断坐标是否超出可见范围
/// </summary>
/// <param name="pos">待检查的坐标值</param>
/// <returns>true表示不可见,false表示可见</returns>
protected bool IsOutRange(float pos)
{
// 获取当前content的偏移量
Vector3 listP = contentRectTrans.anchoredPosition;
// 根据滑动方向分别判断
if (dir == E_Direction.Vertical)
{
// 垂直方向判断:
// - 超过一个单元格高度
// - 完全滚出可视区域底部
if (pos + listP.y > cellH || pos + listP.y < -rectTrans.rect.height)//listP.y为竖向滑动值,向下为负,向上为正
{
return true;
}
}
else
{
// 水平方向判断:
// - 超过一个单元格宽度
// - 完全滚出可视区域右侧
if (pos + listP.x < -cellW || pos + listP.x > rectTrans.rect.width)
{
return true;
}
}
return false;
}
/// <summary>
/// 从对象池获取子项(优先复用)
/// </summary>
/// <returns>可用的子项对象</returns>
protected virtual GameObject GetPoolsObj()
{
GameObject cell = null;
// 优先从对象池获取
if (Pool.Count > 0) cell = Pool.Pop();
// 对象池为空时实例化新对象
if (cell == null) cell = Instantiate(this.cell) as GameObject;
// 设置父对象和缩放
cell.transform.SetParent(content.transform);
cell.transform.localScale = Vector3.one;
// 激活对象
SetActive(cell, true);
return cell;
}
/// <summary>
/// 将子项回收到对象池
/// </summary>
/// <param name="cell">要回收的子项</param>
protected virtual void SetPoolsObj(GameObject cell)
{
// 安全检查
if (cell != null)
{
// 压入对象池
Pool.Push(cell);
// 禁用对象
SetActive(cell, false);
}
}
/// <summary>
/// 执行回调函数的封装方法
/// </summary>
/// <param name="func">回调函数</param>
/// <param name="selectObject">目标子项</param>
/// <param name="isUpdate">是否为更新操作</param>
protected void Func(Action<GameObject, int> func, GameObject selectObject, bool isUpdate = false)
{
// 从子项名称解析索引
int index = int.Parse(selectObject.name);
// 安全执行回调
if (func != null)
{
func(selectObject, index);
}
}
/// <summary>
/// 清理所有回调引用(防止内存泄漏)
/// </summary>
public void DisposeAll()
{
if (FuncCallBackFunc != null) FuncCallBackFunc = null;
if (FuncOnClickCallBack != null) FuncOnClickCallBack = null;
if (FuncOnButtonClickCallBack != null) FuncOnButtonClickCallBack = null;
}
/// <summary>
/// Unity销毁对象时自动调用
/// </summary>
protected void OnDestroy()
{
DisposeAll();
}
/// <summary>
/// 安全设置对象激活状态
/// </summary>
/// <param name="obj">目标对象</param>
/// <param name="isActive">要设置的状态</param>
protected void SetActive(GameObject obj, bool isActive)
{
// 空安全检查
if (obj != null)
{
obj.SetActive(isActive);
}
}
}
}
三、RecycleView 编辑器内可视化代码(RecycleViewEditor)
- 放在Editor文件夹中
using UnityEngine;
using UnityEditor;
namespace InfiniteSlidingList
{
//[CustomEditor(typeof(RecycleView))]将该类标记为 RecycleView 组件的自定义编辑器,覆盖其默认 Inspector 绘制逻辑。
[CustomEditor(typeof(RecycleView))]
public class UICircularScrollViewEditor : Editor
{
//声明一个变量,用于存储当前正在编辑的 RecycleView 组件实例
RecycleView rv;
public override void OnInspectorGUI()//重写覆盖了原先的Inspector,只显示这里的值
{
rv = (RecycleView)target;//target是一个关键属性,表示当前正在被Inspector编辑的对象
//创建一个枚举下拉菜单,用于设置 RecycleView 的滑动方向(E_Direction.Horizontal 或 Vertical)。
//EditorGUILayout.EnumPopup 是 Unity Editor 的 GUI 系统中的一个方法,专门用于在 Inspector 或自定义编辑器窗口中绘制枚举(enum)类型的下拉选择菜单
rv.dir = (E_Direction)EditorGUILayout.EnumPopup("Direction", rv.dir);//EnumPopup返回的是Enum类型,(E_Direction)将返回类型变为E_Direction
//创建一个取值范围为 1~10 的整数滑动条,控制每行/列的子项数量。
rv.lines = EditorGUILayout.IntSlider("Row Or Column", rv.lines, 1, 10);
//创建一个浮点数输入框,用于设置行列间距相同时的统一值。
rv.squareSpacing = EditorGUILayout.FloatField("Square Spacing", rv.squareSpacing);
//创建一个 Vector2 输入控件,分别设置行间距(X)和列间距(Y)
rv.Spacing = EditorGUILayout.Vector2Field("Spacing", rv.Spacing);
EditorGUILayout.HelpBox("当x和y都为0时,使用SquareSpacing作为统一间距;否则分别使用x(列)和y(行)", MessageType.Info);
//分别创建左内边距和顶内边距的浮点数输入框。
rv.paddingLeft = EditorGUILayout.FloatField("Padding Left", rv.paddingLeft);
rv.paddingTop = EditorGUILayout.FloatField("Padding Top", rv.paddingTop);
//创建一个 GameObject 引用拖拽框,用于指定子项预制体
rv.cell = (GameObject)EditorGUILayout.ObjectField("Cell", rv.cell, typeof(GameObject), true);
}
}
}
四、案例代码(RecycleViewTest.cs)
using UnityEngine;
using UnityEngine.UI;
public class RecycleViewTest : MonoBehaviour
{
//存储数据对象
private List<Info> data;
//信息总数
private int ListCount => data.Count;
//绑定具体的ScollView
public RecycleView VerticalScroll;
void Start()
{
//获取数据信息
data = DataMgr.Instance.Data;
StartScrollView();
}
public void StartScrollView()
{
// 1. 初始化(注册 Cell 数据回调)
VerticalScroll.Init(NormalCallBack);
// 2. 显示列表(传入总数量)
VerticalScroll.ShowList(ListCount);
}
/// <summary>
/// Cell 数据绑定与交互逻辑
/// </summary>
private void NormalCallBack(GameObject cell, int index)
{
// 文本内容事件(transform.Find只在当前 Transform 的子层级中查找)
cell.transform.Find("text").GetComponent<Text>().text = data[index].num.ToString();
// 按钮事件(必须先清理旧监听,避免复用导致叠加)
Button btn = cell.transform.Find("btn").GetComponent<Button>();
// 使用Lambad表达式 只能移除所有监听
btn.onClick.RemoveAllListeners();
btn.onClick.AddListener(() =>
{
Debug.Log(index);
});
}
}
五、代码挂载问题及注意事项
代码挂载问题
- RecycleView.cs挂载到Scroll View下,RecycleViewEditor.cs放在Editor文件夹里,RecycleViewTest.cs建议新建一个空物体挂载
注意事项
- 在 ScrollRect → Content 下直接创建一个 Cell 对象,不要删除该对象,
RecycleView会自动获取其RectTransform作为模板 - 如果将 Cell 制作为预制体,要将Cell实例化一个到 Content 下**,将实例拖入
RecycleView获取其RectTransform,不要直接拖预制体资源 到RecycleView.cell - 按钮监听必须 RemoveAllListeners(防止复用叠加)
- 禁止使用 LayoutGroup / ContentSizeFitter
- 列表销毁时会自动清理回调,防止内存泄漏

浙公网安备 33010602011771号