UGUI竖直滚动无限循环列表

主要思路

用几个预先实例化的子item来轮流显示内容,子item前后都可以先进先出,这个过程可以用一个双向队列来实现。

LoopScrollView

主要用到的UGUI组件是ScrollRect,其中viewport属性对应可以看见的视口,content属性对应内容窗口,内容窗口的大小决定了滚动条可以拉动的范围。

虽然实际上只有几个实例化的item,但是为了能够拉动滑动条,还是需要先调整content的高度为所有数据的总高度

1
2
3
4
5
6
private void UpdateContentHeight()
{
var sizeDelta = scrollRect.content.sizeDelta; // RectTransform的大小相对于锚点的距离
sizeDelta.y = itemOrigin.rect.height * dataRowCount + rowPadding * (dataRowCount - 1);
scrollRect.content.sizeDelta = sizeDelta;
}

更新item时首先需要判断item的数据索引是否有效,无效的话就SetActive(false)。因为几个复用的item都有可能马上被显示,所以实际上只有当拉到最上面或者最下面的时候才会有item被取消激活。

1
2
3
4
5
6
var itemTransform = item.RectTransform;
if(dataIndex < 0 || dataIndex >= dataRowCount)
{
itemTransform.gameObject.SetActive(false);
return;
}

之后设置item的坐标,对应的是anchoredPosition属性。首先计算处第一个item当前的坐标,然后按照从上到下的次序减去行高和行间距就得到了对应item的坐标

这里只考虑了垂直滑动

1
2
3
4
5
6
7
private void UpdateItemPosition(RectTransform item, int itemIndex) 
{
Vector2 anchoredPos;
anchoredPos.y = _firstItemY - (_rowHeight * itemIndex + (1 - _itemPivot.y) * _itemRect.height);
anchoredPos.x = _itemPivot.x * _itemRect.width;
item.anchoredPosition = anchoredPos;
}

对于更新item数据,这里声明了一个ILoopScrollItem接口,其中就有UpdateData方法,参数是数据的绝对索引,可以用来指定item需要显示的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface ILoopScrollViewItem
{
/// <summary>
/// 更新数据
/// </summary>
/// <param name="dataIndex">数据绝对索引</param>
void UpdateData(int dataIndex);

RectTransform RectTransform
{
get;
}
}

RectTransform.rect属性在Awake方法甚至Start方法里可能获取到的值都是0(尤其是当设置ScrollView和Viewport的anchors为完全伸展的时候),于是代码里在Init中加了额外判断,而且在Update中进行重新初始化,具体可以看后面的代码

Deque

Deque是一个双向队列,底层数据结构是一个数组,这里是用来高效实现子item的动态进出。如果使用LinkedList,其底层是双向链表,内存占用多一些,遍历开销大,不划算。

Deque提供了MoveToFirstMoveToLast方便快速移动大量首尾元素,而不用一个一个增删,尤其是当队列容量和元素个数相同,也就是满循环队列的时候会更加高效。

Deque还实现了IEnumerable<T>接口,可以支持foreach遍历元素。具体可以查看后面的代码。

这个实现比较简单,没有处理异常,甚至没有考虑扩容,但是在这里是基本够用的

完整代码

具体实现可以参考完整的代码,注释很多。

这里还提供了一个简单的示例:新建一个Canvas,在Content下添加一个Panel,再添加一个Text,然后挂上脚本。结构如下:

1
2
3
4
5
ScrollView // LoopScrollView.cs, Test.cs
Viewport
Content
Panel // TestItem.cs
Text

LoopScrollView.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
using System;
using UnityEngine;
using UnityEngine.UI;

namespace LoopScrollView
{
/// <summary>
/// 循环滚动列表
/// </summary>
public class LoopScrollView : MonoBehaviour
{
[Tooltip("子节点模板")]
public RectTransform itemOriginTransform;
[Tooltip("行间距")]
public float rowPadding = 15;

/// <summary>
/// 视口中添加复用item时
/// </summary>
public event Action<ILoopScrollViewItem> onAddItem; // 考虑到会动态增加item,而item有数据需要初始化

private Deque<ILoopScrollViewItem> items;
/// <summary>
/// 子节点循环队列
/// </summary>
public Deque<ILoopScrollViewItem> Items => items;

// 缓存
private ScrollRect scrollRect;
private RectTransform rectTransform;

// 上下各预留两行
private const int resveredRows = 2;

private int dataRowCount;
/// <summary>
/// 数据总行数,设置之后会更新列表显示
/// </summary>
/// <value></value>
public int DataRowCount
{
get => dataRowCount;
set
{
if(dataRowCount != value)
{
dataRowCount = value;
UpdateContentHeight(); // 更新content高度,对应滚动条才会更新,这样才能拉到下面
UpdateAllItems();
}
}
}

// 间距+一行内容的高度
private float RowHeight => rowPadding + itemOriginTransform.rect.height;

// 可见的视口高度
private float ViewportHeight => scrollRect.viewport.rect.height;//rectTransform.rect.height; // 因为viewport默认anchors是完全伸展,所以大小跟scrollrect一样

// viewport中第一条(最上面)item显示的数据的绝对索引
private int FirstDataIndex => (int)(UpwardScrollHeight / RowHeight);

// 相对于原始状态的上滑量,负数表示向下滑
private float UpwardScrollHeight => scrollRect.content.localPosition.y; // 开始的时候是0

private bool _hasInited, _initSucc;
private void Update()
{
if(_hasInited && !_initSucc) // 因为Start里获取的数据都是0
{
Debug.LogWarning("LoopScrollView ReInit");
Init(dataRowCount, onAddItem); // 重新初始化一次
if(_initSucc){
UpdateContentHeight(); // 数据行数没变,需要手动刷新
UpdateAllItems();
}
}
}

private void OnDisable()
{
scrollRect.onValueChanged.RemoveListener(OnScroll);
}

/// <summary>
/// 初始化
/// </summary>
/// <param name="dataRowCount">指定总数据行数</param>
/// <param name="onAddItem">实例化新的复用item时</param>
public void Init(int dataRowCount, Action<ILoopScrollViewItem> onAddItem)
{
_hasInited = true;
_initSucc = false;

this.onAddItem = onAddItem;
rectTransform = GetComponent<RectTransform>();
scrollRect = GetComponent<ScrollRect>();
if(scrollRect == null)
{
Debug.LogError("LoopScrollView need ScrollRect component!");
return;
}
if(itemOriginTransform == null)
{
Debug.LogError("LoopScrollView need at leaest one child item!");
return;
}
scrollRect.onValueChanged.AddListener(OnScroll);
itemOriginTransform.gameObject.SetActive(false);

if(ViewportHeight == 0)
{
Debug.LogWarning("LoopScrollView Init failed: ViewportHeight == 0");
this.dataRowCount = dataRowCount; // 先记下来,等会初始化
return; // 干脆不继续初始化了
}
Debug.Log("ViewportHeight: " + ViewportHeight);

int itemsCount = 2 * resveredRows + (int)(ViewportHeight / RowHeight + 0.5f); // 铺满视口并加上预留的
items = new Deque<ILoopScrollViewItem>(itemsCount);
items.AddLast(itemOriginTransform.GetComponent<ILoopScrollViewItem>());
onAddItem(items.First);
for(int i = 1; i < itemsCount; i++)
{
items.AddLast(Instantiate(itemOriginTransform, scrollRect.content, false).GetComponent<ILoopScrollViewItem>());
onAddItem(items.Last);
}

DataRowCount = dataRowCount;
_initSucc = true;
Debug.Log("LoopScrollView Reinit Succ");
return;
}

private float _lastPosY = 0;
private void OnScroll(Vector2 normalizedPos) // 滚动时调用
{
float newPosY = scrollRect.content.localPosition.y;
float deltaPosY = Mathf.Abs(newPosY - _lastPosY);
if(deltaPosY >= ViewportHeight)
{
UpdateAllItems();
_lastPosY = newPosY;
}
else if(deltaPosY >= RowHeight) // 至少滑动一个行高之后才需要更新,要注意滑动量也可能不止一个行高
{
int deltaRow = (int)(deltaPosY / RowHeight); // 滑动行数
if(newPosY > _lastPosY)
{
items.MoveToLast(deltaRow); // 上滑出界,放到最后
}
else
{
items.MoveToFirst(deltaRow); // 下滑出界,放到最前
}

UpdateAllItems(); // 其实不一定需要全部更新

_lastPosY = newPosY;
}
}

/// <summary>
/// 更新所有子项
/// </summary>
public void UpdateAllItems()
{
Debug.Log("LoopScrollView UpdateAllItems");
PrepareForUpdateItems();

int itemIndex = -resveredRows, dataIndex = FirstDataIndex - resveredRows; // content中从上到下的item相对索引, 数据绝对索引
foreach(var item in items)
{
UpdateItem(item, itemIndex++, dataIndex++);
}
}

Vector2 _itemPivot; // 相对于itemRect
Rect _itemRect;
float _rowHeight;
float _firstItemY; // viewport中第一个item的y值(相对于content的上边界),下面的逐个递减
private void PrepareForUpdateItems() // 提前计算好items公用的数据,避免在UpdateItem里重复计算
{
_itemPivot = itemOriginTransform.pivot;
_itemRect = itemOriginTransform.rect;
_rowHeight = RowHeight;
_firstItemY = - FirstDataIndex * _rowHeight;
}

// 跟据item的相对索引、数据索引来更新
private void UpdateItem(ILoopScrollViewItem item, int itemIndex, int dataIndex)
{
var itemTransform = item.RectTransform;
if(dataIndex < 0 || dataIndex >= dataRowCount)
{
itemTransform.gameObject.SetActive(false);
return;
}
UpdateItemPosition(itemTransform, itemIndex);
item.UpdateData(dataIndex);
itemTransform.gameObject.SetActive(true); // 数据有效就激活,viewport之外的也是激活的
}

// 更新item实际的坐标
private void UpdateItemPosition(RectTransform item, int itemIndex)
{
Vector2 anchoredPos;
anchoredPos.y = _firstItemY - (_rowHeight * itemIndex + (1 - _itemPivot.y) * _itemRect.height);
anchoredPos.x = _itemPivot.x * _itemRect.width;
item.anchoredPosition = anchoredPos;
}

private void UpdateContentHeight()
{
var sizeDelta = scrollRect.content.sizeDelta;
sizeDelta.y = itemOriginTransform.rect.height * dataRowCount + rowPadding * (dataRowCount - 1);
scrollRect.content.sizeDelta = sizeDelta;
}
}
}

ILoopScrollViewItem.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using UnityEngine;

namespace LoopScrollView
{
public interface ILoopScrollViewItem
{
/// <summary>
/// 更新数据
/// </summary>
/// <param name="dataIndex">数据绝对索引</param>
void UpdateData(int dataIndex);

RectTransform RectTransform
{
get;
}
}
}

Deque.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
using System.Collections;
using System.Collections.Generic;

namespace LoopScrollView
{
/// <summary>
/// 双向循环队列
/// </summary>
/// <typeparam name="T"></typeparam>
public class Deque<T> : IEnumerable<T>
{
public Deque(int capcity)
{
data = new T[capcity <= 0 ? 1 : capcity]; // 不抛出异常
first = 0;
last = 0;
count = 0;
}

public void AddFirst(T value)
{
if(count < data.Length) // 没有考虑扩容
{
if(first == 0)
{
first = data.Length - 1;
data[first] = value;
}
else
{
data[--first] = value;
}
count++;
}
}

public void AddLast(T value)
{
if(Count < data.Length) // 没有考虑扩容
{
if(last == data.Length - 1)
{
data[last] = value;
last = 0;
}
else
{
data[last++] = value;
}
count++;
}
}

public T RemoveFirst()
{
T ret = default(T);
if(Count > 0)
{
ret = data[first];
data[first] = default(T);
first = first == data.Length - 1 ? 0 : first + 1;
count--;
}
return ret;
}

public T RemoveLast()
{
T ret = default(T);
if(Count > 0)
{
if(last == 0)
{
last = data.Length - 1;
ret = data[last];
data[last] = default(T);
}
else
{
ret = data[--last];
data[last] = default(T);
}
count--;
}
return ret;
}

/// <summary>
/// 移动后面n个元素到前面,结果等同于执行n次AddFIrst(RemoveLast()),但是更高效
/// </summary>
/// <param name="n"></param>
public void MoveToFirst(int n)
{
if(n <= 0 || n >= count){
return;
}
if(count == data.Length) // 满循环队列,last==first,移动指针即可
{
first = last - n;
if(first < 0){
first += count;
}
last = first;
}
else
{
var tmp = data.Clone() as T[];
int index1 = first, index2 = (first + n) % count;
do{
data[index1++] = tmp[index2++];
index1 %= count;
index2 %= count;
}while(index1 != last);
}
}

/// <summary>
/// 移动前面n个元素到后面,结果等同于执行n次AddLast(RemoveFirst()),但是更高效
/// </summary>
/// <param name="n"></param>
public void MoveToLast(int n)
{
MoveToFirst(count - n);
}

public IEnumerator<T> GetEnumerator()
{
return new Enumerator(this);
}

IEnumerator IEnumerable.GetEnumerator()
{
return new Enumerator(this);
}

public int Count => count;

/// <summary>
/// First element
/// </summary>
/// <value></value>
public T First => data[first];

/// <summary>
/// Last element
/// </summary>
/// <value></value>
public T Last => last == 0 ? data[data.Length - 1] : data[last - 1];

public T this[int i] => data[(i + first) % data.Length];

private T[] data;
private int first;
private int last; // next empty position
private int count; // tell whether is empty or full

public struct Enumerator : IEnumerator<T>
{
private Deque<T> deque;
private int index;
private bool started;

public Enumerator(Deque<T> deque)
{
this.deque = deque;
index = deque.first - 1;
started = false;
}

public T Current => deque.data[index];

object IEnumerator.Current => deque.data[index];

public void Dispose() {}

public bool MoveNext()
{
index = index == deque.data.Length - 1 ? 0 : index + 1;
bool ret;
if(deque.last > deque.first)
{
ret = index >= deque.first && index < deque.last;
}
else if(deque.last < deque.first)
{
ret = !(index >= deque.last && index < deque.first);
}
else
{
ret = !(started && index == deque.first); // firstly visit First
}

if(!started)
{
started = true;
}
return ret;
}

public void Reset()
{
index = deque.first - 1;
started = false;
}
}
}
}

Test.cs

1
2
3
4
5
6
7
8
9
10
11
12
using UnityEngine;

namespace LoopScrollView
{
public class Test : MonoBehaviour
{
void Start()
{
GetComponent<LoopScrollView>().Init(1000, (_) => { });
}
}
}

TestItem.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using UnityEngine;
using UnityEngine.UI;

namespace LoopScrollView
{
public class TestItem : MonoBehaviour, ILoopScrollViewItem
{
private RectTransform _rectTransform;
public RectTransform RectTransform => _rectTransform ??= GetComponent<RectTransform>();

private Text text;
public void UpdateData(int dataIndex)
{
(text ??= GetComponentInChildren<Text>()).text = dataIndex.ToString();
}
}
}