前言
当我们需要在游戏中显示非常多的数据项时,例如背包列表,无尽关卡列表等,如果全部将数据项加载进游戏内,不但十分会影响性能,内存也会突然增长,会有突然crash的风险,所以我们必须要实现一个可以按需加载数据项和无限循环滑动的列表。Unity内置的ScrollView 并不能满足我们的要求,但我们可以利用它来扩展、实现我们想要的功能。在参考网友已有代码的基础上,自己再按需改造了一翻,最后的效果非常不错,已经应用在项目中了。下面我们一起来看看怎么实现这个无限循环滑动列表的。
一、定义数据项对象
SV滚动的不只是UI,其实它本质滚动的是我们的数据,所以我们需要定义这样的一个数据项对象。它是一个基类,里面只定义了 SV 要用到的基本数据,其它业务需要的数据由使用者继承定义。
代码如下:
/// <summary>
/// 项数据
/// </summary>
public class ItemData
{
public ItemData() { }
public int Id;
public RectTransform ItemRect;
/// <summary>
/// 在数据列表中的索引
/// </summary>
public int DataIndex;
}
二、定义数据滚动列表
由于我们滚动的是数据而非UI,UI是随着数据的变化而变化的,所以我们还需要定义和实现一个数据滚动列表。它的功能包括列表内的头尾自动交换相接,形成无限循环。
代码如下:
using System.Collections;
using System.Collections.Generic;
namespace Simple.UI
{
public class ScrollItems<T> where T : ItemData
{
private List<T> _items;
public int Count => _items.Count;
public ScrollItems()
{
_items = new List<T>();
}
public ScrollItems(T[] ts)
{
if (ts == null)
return;
for (int i = 0; i < ts.Length; i++)
{
_items.Add(ts[i]);
}
}
public T this[int index]
{
get
{
return _items[index];
}
}
public void Add(T t)
{
_items.Add(t);
}
public T MoveFirst2last()
{
if (_items.Count == 0)
return default(T);
T t = _items[0];
_items.RemoveAt(0);
t.DataIndex = _items[_items.Count - 1].DataIndex + 1;
_items.Add(t);
return t;
}
public T MoveLast2first()
{
if (_items.Count == 0)
return default(T);
T t = _items[_items.Count - 1];
_items.RemoveAt(_items.Count - 1);
t.DataIndex = _items[0].DataIndex - 1;
_items.Insert(0, t);
return t;
}
public T[] GetItems()
{
T[] ts = new T[_items.Count];
for (int i = 0; i < _items.Count; i++)
{
ts[i] = _items[i];
}
return ts;
}
public void Clear()
{
_items.Clear();
}
}
}
三、定义SV基类
由于我们需要实现水平和垂直滚动条,所以最好的解决办法依然是继承基类,然后子类分别实现。
基类的首要内容是包括滚动的数据列表和用户设置,代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
namespace Simple.UI
{
public enum ScrollType
{
Up,
Down,
Left,
Right,
Stop
}
public abstract class ScrollLoop
{
public ScrollItems<ItemData> Items = new ScrollItems<ItemData>();
public ScrollLoopView LoopView;
public RectTransform LoopTransform;
public ScrollRect LoopRect;
public ScrollType MoveType;
public bool Horizontal = false;
public float ContentHeight;
public float ContentWidth;
public bool AutoViewWH
{
get { return LoopView.AutoViewWH; }
set { LoopView.AutoViewWH = value; }
}
public float ViewHeight
{
get { return LoopView.ViewHeight; }
set { LoopView.ViewHeight = value; }
}
public float ViewWidth
{
get { return LoopView.ViewWidth; }
set { LoopView.ViewWidth = value; }
}
public float ItemWidth
{
get { return LoopView.ItemWidth; }
set { LoopView.ItemWidth = value; }
}
public float ItemHeight
{
get { return LoopView.ItemHeight; }
set { LoopView.ItemHeight = value; }
}
public int ViewInNum
{
get { return LoopView.ViewInNum; }
set { LoopView.ViewInNum = value; }
}
public int ViewOutNum
{
get { return LoopView.ViewOutNum; }
set { LoopView.ViewOutNum = value; }
}
public int ScrollIndexOffset
{
get { return LoopView.ScrollIndexOffset; }
set { LoopView.ScrollIndexOffset = value; }
}
public int AllNum
{
get { return LoopView.AllNum; }
set { LoopView.AllNum = value; }
}
public float SpaceTop
{
get { return LoopView.SpaceTop; }
set { LoopView.SpaceTop = value; }
}
public float SpaceBottom
{
get { return LoopView.SpaceBottom; }
set { LoopView.SpaceBottom = value; }
}
public float SpaceLeft
{
get { return LoopView.SpaceLeft; }
set { LoopView.SpaceLeft = value; }
}
public float SpaceRight
{
get { return LoopView.SpaceRight; }
set { LoopView.SpaceRight = value; }
}
public float SpaceY
{
get { return LoopView.SpaceY; }
set { LoopView.SpaceY = value; }
}
public float SpaceX
{
get { return LoopView.SpaceX; }
set { LoopView.SpaceX = value; }
}
public ScrollLoop(ScrollLoopView scrollLoop)
{
this.LoopView = scrollLoop;
LoopTransform = this.LoopView.GetComponent<RectTransform>();
LoopRect = this.LoopView.GetComponent<ScrollRect>();
if (!LoopRect)
{
LoopRect = this.LoopView.gameObject.AddComponent<ScrollRect>();
}
}
private void CreateItem<T>() where T : ItemData, new()
{
if (LoopView.ItemPrefab == null)
{
Debug.LogError($"============{nameof(CreateItem)} 请设置 ScrollViewLoop.ItemPrefab 的值");
return;
}
Items.Clear();
var showNum = ViewInNum + ViewOutNum;
for (int i = 0; i < showNum; i++)
{
var item = GameObject.Instantiate(LoopView.ItemPrefab);
var data = CreateItemObj<T>(item, i);
Items.Add(data);
LoopView.ItemChange(data);
}
}
private void CreateItem<T>(GameObject[] items) where T : ItemData, new()
{
var showNum = ViewInNum + ViewOutNum;
if (items.Length < showNum)
{
Debug.LogError($"============{nameof(CreateItem)} 项数量必须和ScrollViewLoop的设置相等(ViewInNum + ViewOutNum)");
return;
}
Items.Clear();
for (int i = 0; i < showNum; i++)
{
var item = items[i];
var data = CreateItemObj<T>(item, i);
Items.Add(data);
LoopView.ItemChange(data);
}
}
/// <summary>
/// 绘制sv, 可自定义传入UI对象,而不是直接设置,例如你想利用缓存内的对象时
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="items"></param>
public void DrawScrollView<T>(GameObject[] items) where T : ItemData, new()
{
if (items == null)
CreateItem<T>();
else
CreateItem<T>(items);
OnDrawView();
}
public virtual void Dispose()
{
if (LoopView.ItemPrefab != null)
{
for (int i = 0; i < Items.Count; i++)
{
var data = Items[i];
GameObject.Destroy(data.ItemRect.gameObject);
}
}
Items.Clear();
}
protected virtual T CreateItemObj<T>(GameObject item, int i) where T : ItemData, new()
{
var itemRect = item.GetComponent<RectTransform>();
var data = new T();
data.Id = i;
data.ItemRect = itemRect;
data.DataIndex = i;
return data;
}
public virtual void Scroll2Pos(int dataIndex) { }
protected abstract void OnDrawView();
public abstract void Update(float deltatime);
}
}
四、定义用户设置
想象一下,我们使用SV时,是需要提供给使用者一些参数设置的,从而方便可视化调整和测试,所以我们还要定义一个MonoBehaviour类,开放一些变量给使用者。
代码如下:
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Simple.UI
{
public class ScrollLoopView : MonoBehaviour
{
public GameObject ItemPrefab;
[Header("项宽度")]
public float ItemWidth;
[Header("项高度")]
public float ItemHeight;
[Header("显示区内项数量")]
public int ViewInNum = 6;
[Header("显示区外项数量")]
public int ViewOutNum = 2;
[Header("自动根据内项数量计算显示区宽高度")]
public bool AutoViewWH = false;
[Header("显示区宽度")]
public float ViewWidth;
[Header("显示区高度")]
public float ViewHeight;
[Header("滚动到指定项时相对于容器第一项的索引偏移")]
public int ScrollIndexOffset;
[Header("所有项数量")]
public int AllNum = 100;
public float SpaceTop = 0;
public float SpaceBottom = 0;
public float SpaceLeft = 0;
public float SpaceRight = 0;
public float SpaceY = 0;
public float SpaceX = 0;
public bool Horizontal = false;
/// <summary>
/// 项位置改变时调用的事件
/// </summary>
public event Action<ItemData> OnItemChange;
private ScrollLoop _scroll;
public ScrollLoop Scroll => _scroll;
public ScrollItems<ItemData> GetItems()
{
return _scroll.Items;
}
public void CreateScroll()
{
if (Horizontal)
_scroll = new HScroll(this);
else
_scroll = new VScroll(this);
}
public void CreateScroll(ScrollLoop scroll)
{
Horizontal = scroll.Horizontal;
_scroll = scroll;
}
public void DrawScroll<T>(GameObject[] items) where T : ItemData, new()
{
_scroll.DrawScrollView<T>(items);
}
public void ItemChange(ItemData itemData)
{
OnItemChange?.Invoke(itemData);
}
void Update()
{
if (_scroll != null)
{
_scroll.Update(Time.deltaTime);
}
}
}
}
最终可能形成的效果如下所示:
五、实现垂直滚动条
基类ScrollLoop的作用只要提供数据列表和通用接口和方法,具体的功能实现还得靠子类。
垂直滚动条继承于ScrollLoop,代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Simple.UI
{
public class VScroll : ScrollLoop
{
private float _lastY = 0;
private ScrollType _lastMoveType = ScrollType.Stop;
public VScroll(ScrollLoopView scrollLoop) : base(scrollLoop)
{
Horizontal = false;
LoopRect.horizontal = false;
LoopRect.vertical = true;
}
protected override T CreateItemObj<T>(GameObject item, int i)
{
var data = base.CreateItemObj<T>(item, i);
//统一设置为上部中间的锚点,方便计算距离和偏移
var itemRect = data.ItemRect;
itemRect.sizeDelta = new Vector2(ItemWidth, ItemHeight);
itemRect.anchorMin = new Vector2(0.5f, 1);
itemRect.anchorMax = new Vector2(0.5f, 1);
itemRect.pivot = new Vector2(0.5f, 1);
item.transform.parent = LoopRect.content;
item.transform.localPosition = new Vector3(0, (-i) * (ItemHeight + SpaceY) - SpaceTop, 0); //由于锚点是顶部中间,y坐标计算是从顶部向下的负数,项序X(项高+间距)-上边距 = 项的y坐标
itemRect.localScale = Vector2.one;
return data;
}
/// <summary>
/// 重设坐标
/// </summary>
private void ResetItemPos()
{
//项的索引位置计算,要减去上下空白
int index = Mathf.FloorToInt(Mathf.Abs(LoopRect.content.localPosition.y - SpaceTop - SpaceBottom) / (ItemHeight + SpaceY));
var count = AllNum - index;
if (count < Items.Count)
{//项的位置超内容框时,重置索引位置,拉回滚动框内
index = AllNum - Items.Count;
_lastY = LoopRect.content.localPosition.y;
}
for (int i = 0; i < Items.Count; i++)
{
var item = Items[i];
var itemRect = item.ItemRect;
item.DataIndex = index;
var pos = itemRect.localPosition;
itemRect.transform.localPosition = new Vector3(pos.x, (-item.DataIndex) * (ItemHeight + SpaceY) - SpaceTop, 0);
LoopView.ItemChange(item);
index++;
}
}
/// <summary>
/// 滚动到某个数据项
/// </summary>
/// <param name="dataIndex">数据索引</param>
public override void Scroll2Pos(int dataIndex)
{
var pos = LoopRect.content.localPosition;
var p = (dataIndex - LoopView.ScrollIndexOffset) / (float)AllNum; //由于是顶部为起始点,为了让索引滚动到列表中间,要减去偏移项
var py = (ContentHeight - SpaceTop - SpaceBottom) * p + SpaceTop;//同样滚动框高度要减去上下空白才能精确计算数据项位置
LoopRect.content.localPosition = new Vector3(pos.x, py, pos.z);
}
protected override void OnDrawView()
{
//默认计算高度时,视口框将会按照框内显示的数据项计算高度,否则保持预设的高度不变
if (AutoViewWH)
{
ViewHeight = ViewInNum * ItemHeight + (ViewInNum - 1) * SpaceY;
ViewWidth = ItemWidth;
ContentWidth = ItemWidth;
}
else
{
ViewHeight = LoopRect.viewport.sizeDelta.y;
ViewWidth = LoopRect.viewport.sizeDelta.x;
ContentWidth = LoopRect.content.sizeDelta.x;
}
SetAnchorAndPivot2topCenter(LoopTransform);
LoopTransform.sizeDelta = new Vector2(ViewWidth, ViewHeight);
SetAnchorAndPivot2topCenter(LoopRect.viewport);
LoopRect.viewport.sizeDelta = new Vector2(ViewWidth, ViewHeight);
LoopRect.viewport.localPosition = Vector3.zero;
SetAnchorAndPivot2topCenter(LoopRect.content);
//总的内容高度应该包括所有数据项高度+间距+上下空白
ContentHeight = AllNum * ItemHeight + (AllNum - 1) * SpaceY + SpaceTop + SpaceBottom;
LoopRect.content.sizeDelta = new Vector2(ContentWidth, ContentHeight);
LoopRect.content.localPosition = Vector3.zero;
}
/// <summary>
/// 设置锚点和重心点为顶部,方便计算坐标和距离
/// </summary>
/// <param name="rect"></param>
private void SetAnchorAndPivot2topCenter(RectTransform rect)
{
rect.anchorMin = new Vector2(0.5f, 1);
rect.anchorMax = new Vector2(0.5f, 1);
rect.pivot = new Vector2(0.5f, 1);
rect.localScale = Vector2.one;
}
public override void Update(float deltatime)
{
float y = LoopRect.content.localPosition.y;
if (y > _lastY)
MoveType = ScrollType.Up;
else if (y < _lastY)
MoveType = ScrollType.Down;
else
{
MoveType = ScrollType.Stop;
if (_lastMoveType != MoveType)
ResetItemPos();
}
if (MoveType == ScrollType.Up)//向上滑动
{
if (Items[Items.Count - 1].DataIndex == AllNum - 1)
return;
var distMove = ItemHeight * 2 - SpaceY * 2;//超框到指定距离是移动
float firstItemY = Mathf.Abs(Items[0].ItemRect.localPosition.y);
if (y - firstItemY - distMove >= 0)//顶部超2项时移动到尾部
{
float lastItemY = Items[Items.Count - 1].ItemRect.localPosition.y;
ItemData firstItem = Items.MoveFirst2last();
firstItem.ItemRect.localPosition = new Vector3(firstItem.ItemRect.localPosition.x, lastItemY - SpaceY - ItemHeight, 0);
if (firstItem.DataIndex < AllNum)
{
LoopView.ItemChange(firstItem);
}
}
}
else if (MoveType == ScrollType.Down)//向下滑动
{
if (Items[0].DataIndex == 0)
return;
var distMove = ItemHeight * 1 - SpaceY * 1;//超框到指定距离是移动
float lastItemY = Mathf.Abs(Items[Items.Count - 1].ItemRect.localPosition.y);
if (lastItemY - y - ViewHeight - distMove >= 0)
{
float firstItemY = Items[0].ItemRect.localPosition.y;
ItemData lastItem = Items.MoveLast2first();
lastItem.ItemRect.localPosition = new Vector3(lastItem.ItemRect.localPosition.x, firstItemY + SpaceY + ItemHeight, 0);
if (lastItem.DataIndex >= 0)
{
LoopView.ItemChange(lastItem);
}
}
}
_lastMoveType = MoveType;
_lastY = y;
}
}
}
六、实现水平滚动条
水平滚动条实现思路其实是和垂直一样的,只不过是把y坐标变成x坐标,把上中锚点变成左中锚点,上下空白变成左右空白,其它的细节不会变化太大。
代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace Simple.UI
{
public class HScroll : ScrollLoop
{
private float _lastX = 0;
private ScrollType _lastMoveType = ScrollType.Stop;
public HScroll(ScrollLoopView scrollLoop) : base(scrollLoop)
{
LoopRect.horizontal = true;
LoopRect.vertical = false;
}
protected override T CreateItemObj<T>(GameObject item, int i)
{
var data = base.CreateItemObj<T>(item, i);
var itemRect = data.ItemRect;
itemRect.sizeDelta = new Vector2(ItemWidth, ItemHeight);
itemRect.anchorMin = new Vector2(0, 0.5f);
itemRect.anchorMax = new Vector2(0, 0.5f);
itemRect.pivot = new Vector2(0, 0.5f);
item.transform.parent = LoopRect.content;
item.transform.localPosition = new Vector3(i * (ItemWidth + SpaceX) - SpaceLeft, 0, 0);
itemRect.localScale = Vector2.one;
return data;
}
/// <summary>
/// 重设坐标
/// </summary>
private void ResetItemPos()
{
//项的索引位置计算,要减去上下空白
int index = Mathf.FloorToInt(Mathf.Abs(LoopRect.content.localPosition.x - SpaceLeft - SpaceRight) / (ItemHeight + SpaceX));
var count = AllNum - index;
if (count < Items.Count)
{//项的位置超内容框时,重置索引位置,拉回滚动框内
index = AllNum - Items.Count;
_lastX = LoopRect.content.localPosition.x;
}
for (int i = 0; i < Items.Count; i++)
{
var item = Items[i];
var itemRect = item.ItemRect;
item.DataIndex = index;
var pos = itemRect.localPosition;
itemRect.transform.localPosition = new Vector3(item.DataIndex * (ItemWidth + SpaceX) - SpaceLeft, pos.y, 0);
LoopView.ItemChange(item);
index++;
}
}
/// <summary>
/// 滚动到某个数据项
/// </summary>
/// <param name="dataIndex">数据索引</param>
public override void Scroll2Pos(int dataIndex)
{
var pos = LoopRect.content.localPosition;
var p = (dataIndex - LoopView.ScrollIndexOffset) / (float)AllNum;
var px = (ContentWidth - SpaceLeft - SpaceRight) * p + SpaceLeft;
LoopRect.content.localPosition = new Vector3(px, pos.y, pos.z);
}
protected override void OnDrawView()
{
if (AutoViewWH)
{
ViewHeight = ItemHeight;
ViewWidth = ViewInNum * ItemWidth + (ViewInNum - 1) * SpaceX;
ContentHeight = ItemHeight;
}
else
{
ViewHeight = LoopRect.viewport.sizeDelta.y;
ViewWidth = LoopRect.viewport.sizeDelta.x;
ContentHeight = LoopRect.content.sizeDelta.y;
}
SetAnchorAndPivot2topCenter(LoopTransform);
LoopTransform.sizeDelta = new Vector2(ViewWidth, ViewHeight);
SetAnchorAndPivot2topCenter(LoopRect.viewport);
LoopRect.viewport.sizeDelta = new Vector2(ViewWidth, ViewHeight);
LoopRect.viewport.localPosition = Vector3.zero;
SetAnchorAndPivot2topCenter(LoopRect.content);
ContentWidth = AllNum * ItemWidth + (AllNum - 1) * SpaceX + SpaceLeft + SpaceRight;
LoopRect.content.sizeDelta = new Vector2(ContentWidth, ContentHeight);
LoopRect.content.localPosition = Vector3.zero;
}
/// <summary>
/// 设置锚点和重心点为顶部,方便计算坐标和距离
/// </summary>
/// <param name="rect"></param>
private void SetAnchorAndPivot2topCenter(RectTransform rect)
{
rect.anchorMin = new Vector2(0, 0.5f);
rect.anchorMax = new Vector2(0, 0.5f);
rect.pivot = new Vector2(0, 0.5f);
rect.localScale = Vector2.one;
}
public override void Update(float deltatime)
{
float x = LoopRect.content.localPosition.x;
if (x > _lastX)
MoveType = ScrollType.Right;
else if (x < _lastX)
MoveType = ScrollType.Left;
else
{
MoveType = ScrollType.Stop;
if (_lastMoveType != MoveType)
ResetItemPos();
}
if (MoveType == ScrollType.Left)
{
if (Items[Items.Count - 1].DataIndex == AllNum - 1)
{
return;
}
var distMove = ItemWidth * 2 - SpaceX * 2;//超框到指定距离是移动
float firstItemX = Mathf.Abs(Items[0].ItemRect.localPosition.x);
if (x - firstItemX - distMove >= 0)//顶部超2项时移动到尾部
{
float lastItemX = Items[Items.Count - 1].ItemRect.localPosition.x;
ItemData firstItem = Items.MoveFirst2last();
firstItem.ItemRect.localPosition = new Vector3(lastItemX - SpaceX - ItemWidth, firstItem.ItemRect.localPosition.y, 0);
if (firstItem.DataIndex < AllNum)
{
LoopView.ItemChange(firstItem);
}
}
}
else if (MoveType == ScrollType.Right)
{
if (Items[0].DataIndex == 0)
{
return;
}
var distMove = ItemWidth * 1 - SpaceX * 1;//超框到指定距离是移动
float lastItemX = Mathf.Abs(Items[Items.Count - 1].ItemRect.localPosition.x);
if (lastItemX - x - ViewWidth - distMove >= 0)
{
float firstItemX = Items[0].ItemRect.localPosition.x;
ItemData lastItem = Items.MoveLast2first();
lastItem.ItemRect.localPosition = new Vector3(firstItemX + SpaceX + ItemWidth, lastItem.ItemRect.localPosition.y, 0);
if (lastItem.DataIndex >= 0)
{
LoopView.ItemChange(lastItem);
}
}
}
_lastMoveType = MoveType;
_lastX = x;
}
}
}
七、使用方式
先看设置方式,如下图:
从上图所示,关键点是内置SV的锚点和重心点的设置,sv和viewport和content三者都是一样的。
看代码的调用:
查找相关sv对象,这部分可替换为我们自己的代码:
private ScrollLoopView _svLoop;
private ScrollRect _svStages;
//以下代码可在Start 或 Awake 中执行
_svStages = _getter.GetElement("svStages").GetComponent<ScrollRect>();
_svLoop = _svStages.GetComponent<ScrollLoopView>();
_svLoop.OnItemChange += OnItemChange;
_svLoop.AllNum = InitDataDefine.MaxLevelIndex / _svSingleItemCount;
var itemCount = _svLoop.ViewInNum + _svLoop.ViewOutNum;
var items = new GameObject[itemCount];
for (int i = 0; i < itemCount; i++)
{
var fo = Pool.SpawnOut("Pre_UI_Stage_Content_Group_Item", true, 0);//利用缓存内的UI
items[i] = fo.ActiveObject;
}
_svLoop.CreateScroll();
_svLoop.DrawScroll<StageGroupData>(items);
事件调用:
private void OnItemChange(ItemData data)
{
RefreshLevel(data);
}
刷新列表UI,可换成自己的业务代码:
private void RefreshLevel(ItemData data)
{
var groupData = (StageGroupData)data;
var los = data.ItemRect.gameObject;
if(groupData.GroupBinder == null)
groupData.GroupBinder = los.GetComponent<UIStageGroupBinder>();
var gb = groupData.GroupBinder;
gb.ShowBgBottom(false);
gb.ShowBgTop(false);
gb.ShowEndButton(false);
if (data.DataIndex >= _svLoop.AllNum - 1)
gb.ShowBgBottom(true);
else if (data.DataIndex <= 0)
{
gb.ShowBgTop(true);
gb.ShowEndButton(true);
}
。。。。。。
}
八、使用效果
我们来看一看游戏的Scene视图,如下:
当滚动时,view out 项的首项和尾项会自动交替变换位置,实现无限循环滑动。
再看游戏的game 视图,如下:
滚动时,始终显示view in 内的项,加载新项和删除旧项都是在view out 范围内完成,丝毫不影响view in 内的项显示,也不会出现衔接不上或空白的情况。
感谢阅读!