FishPlayer

一个喜欢摸鱼的废物

0%

Unity UGUI 简单循环滚动列表

之前做了一个自带简单 GridLayout 的循环滚动列表。但是基于继承的思路做的。导师指出了其不便指出,而且Unity其实是比较提倡大家使用组合模式的,所以我决定修改一下。

循环滚动

思路

和在网上能搜索到的大多数循环滚动列表一样,我有一个空的 Content,调整其尺寸来模拟列表的滚动,再根据滚动的当前位置在视口里把UI组件都摆放好。

我需要使用Unity自带的滚动视外,并且我还定义了简单的 ListElement, ListView, ScrollrectController 这三个类。

ListElement 就是实际需要被操作的列表组件,这个组件负责提供 RectTransform 和一个 index, 保证其能被我的 ScrollrectController 操作。

ListView 负责管理 ListElement,生成销魂都可以。同时还提供直接拿取所有 ListElement 的接口。

ScrollrectController 持有 ListView,并且根据用户传入的一个数据个数来进行循环滚动的管理。同时有事件告知每次的布局刷新的完成。

为了方便,我还做了一个比较简单的 GridLayout 给 ScrollrectController 控制。

至于视图内的最大可视条目数,老实说我感觉我没有特别好的方法去计算一个准确的值。所以就按着 (row + 2) (column 2) 来计算了,虽然有一点浪费内存,但在我当前设置的滚动速度下是够用的。

代码

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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397

public class TempListElementUI : MonoBehaviour
{
[Header("must have"), Tooltip("should inherit from ISetupable")]
[SerializeField]
Component m_dataReceiver;
[SerializeField, Tooltip("better to manual drag it in")]
RectTransform m_elementTransform;
[SerializeField, ReadOnly]
private int m_index = -1;
public int ElementIndex => m_index;
public RectTransform ElementRectTransform
{
get
{
if (m_elementTransform == null)
m_elementTransform = GetComponent<RectTransform>();
return m_elementTransform;
}
}

public void Setup<TData>(TData data)
{
SomeUtils.ISetup<Component, TData>(m_dataReceiver, data);
}

public void Show()
{
if (!this.gameObject.activeSelf)
this.gameObject.SetActive(true);
}

public void Hide()
{
if (this.gameObject.activeSelf)
this.gameObject.SetActive(false);
}

public void SetIndex(int index)
{
m_index = index;
}

private void Awake()
{
if (m_elementTransform == null)
m_elementTransform = this.transform as RectTransform;
}

#if UNITY_EDITOR
private void Reset()
{
m_elementTransform = this.transform as RectTransform;
}
#endif
}

public class TempListView : MonoBehaviour
{
#if UNITY_EDITOR
#endif
// check when prefab change
// [OnValueChanged("OnPrefabChanged")]
[SerializeField, Header("place holder, the listview should only contains one type of element")]
TempListElementUI m_elementPrefab;
RectTransform Container => transform as RectTransform;
[SerializeField, ReadOnly]
List<TempListElementUI> m_actualUsedComponents = new List<TempListElementUI>(0);

public int Count => m_actualUsedComponents.Count;
public TempListElementUI this[int index] => m_actualUsedComponents[index];
public IReadOnlyList<TempListElementUI> ElementList => m_actualUsedComponents;

public TempListElementUI Add()
{
TempListElementUI element = InternalAdd();
m_actualUsedComponents.Add(element);
element.Show();
return element;
}

public void Clear()
{
TempListElementUI element = null;
while (m_actualUsedComponents.Count > 0)
{
element = m_actualUsedComponents[m_actualUsedComponents.Count - 1];
m_actualUsedComponents.RemoveAt(m_actualUsedComponents.Count - 1);
InternalRemove(element);
}
}

public void Remove(TempListElementUI instance)
{
if (m_actualUsedComponents.Remove(instance))
{
instance.Hide();
}
else
{
Debug.LogError($"listview_{this.gameObject.name} does not contains {instance.ElementRectTransform.name}, remove failed");
}
}

public void RemoveAt(int index)
{
if (index < 0 || index >= m_actualUsedComponents.Count)
{
Debug.LogError($"{index} is invalid for SimpleListView", this.gameObject);
return;
}

TempListElementUI toRemove = m_actualUsedComponents[index];
m_actualUsedComponents.RemoveAt(index);
InternalRemove(toRemove);
}

public void InnerSwap(int indexA, int indexB)
{
if (indexA < 0 || indexA > m_actualUsedComponents.Count - 1 || indexB < 0 || indexB > m_actualUsedComponents.Count - 1)
{
return;
}

TempListElementUI temp = m_actualUsedComponents[indexA];
int transformIndexA = temp.ElementRectTransform.GetSiblingIndex();
int transformIndexB = m_actualUsedComponents[indexB].ElementRectTransform.GetSiblingIndex();
m_actualUsedComponents[indexA] = m_actualUsedComponents[indexB];
m_actualUsedComponents[indexB] = temp;
m_actualUsedComponents[indexA].ElementRectTransform.SetSiblingIndex(transformIndexA);
m_actualUsedComponents[indexB].ElementRectTransform.SetSiblingIndex(transformIndexB);
}

/// <summary>
///
/// </summary>
/// <returns>-1 means element is not in the list</returns>
public int IndexOf(TempListElementUI instance)
{
try
{
return m_actualUsedComponents.IndexOf(instance);
}
catch (ArgumentOutOfRangeException)
{
return -1;
}
}

protected virtual TempListElementUI InternalAdd()
{
// can apply object pool here
if (Application.isEditor && !Application.isPlaying)
return UnityEditor.PrefabUtility.InstantiatePrefab(m_elementPrefab, Container) as TempListElementUI;
return Instantiate(m_elementPrefab, Container);
}

protected virtual void InternalRemove(TempListElementUI element)
{
// can apply object pool here
if (element == null) return;
element.Hide();
if (Application.isEditor && !Application.isPlaying)
GameObject.DestroyImmediate(element.ElementRectTransform.gameObject);
else
GameObject.Destroy(element.ElementRectTransform.gameObject);
}

#if UNITY_EDITOR

private void OnTransformChildrenChanged()
{
if (Application.isEditor && !Application.isPlaying)
FindPrefabInstances();
}

private void OnPrefabChanged()
{
// remove pre objects
int amount = m_actualUsedComponents.Count;
for (int i = 0; i < m_actualUsedComponents.Count; i++)
{
GameObject.DestroyImmediate(m_actualUsedComponents[i].ElementRectTransform.gameObject);
}

if (m_elementPrefab != null)
{
for (int i = 0; i < amount; i++)
{
RectTransform rectTransform = (RectTransform)UnityEditor.PrefabUtility.InstantiatePrefab(m_elementPrefab, Container);
m_actualUsedComponents[i] = (rectTransform.GetComponent<TempListElementUI>());
}
}
else
{
m_actualUsedComponents.Clear();
}
}

/// <summary>
/// Retrieves prefab instances in the transform
/// </summary>
[ContextMenu("find prefab instances")]
private void FindPrefabInstances()
{
bool hasPrefab = !(m_elementPrefab == null);
TempListElementUI elementPrefab = m_elementPrefab.GetComponent<TempListElementUI>();
m_actualUsedComponents.Clear();
List<GameObject> toDeleteObjectList = new List<GameObject>();
foreach (Transform child in Container)
{
TempListElementUI childElement = child.GetComponent<TempListElementUI>();
if (childElement == null)
{
toDeleteObjectList.Add(child.gameObject);
continue;
}

if (hasPrefab)
{
GameObject detectPrefabGo = UnityEditor.PrefabUtility.GetCorrespondingObjectFromSource(child.gameObject);
TempListElementUI detectPrefab = (detectPrefabGo == null) ? null : detectPrefabGo.GetComponent<TempListElementUI>();
if (elementPrefab == detectPrefab)
{
// same source prefab
m_actualUsedComponents.Add(childElement);
}
else
{
// different source prefab, delete this one
toDeleteObjectList.Add(child.gameObject);
}
}
else if (UnityEditor.PrefabUtility.IsAnyPrefabInstanceRoot(child.gameObject))
{
// find the first prefab
GameObject prefab = UnityEditor.PrefabUtility.GetCorrespondingObjectFromSource(child.gameObject);
m_elementPrefab = prefab.GetComponent<TempListElementUI>();
m_actualUsedComponents.Add(childElement);
hasPrefab = true;
}
}

for (int i = 0; i < toDeleteObjectList.Count; i++)
{
if (Application.isPlaying)
GameObject.Destroy(toDeleteObjectList[i]);
else
GameObject.DestroyImmediate(toDeleteObjectList[i]);
}
}

[ContextMenu("editor time add")]
private void EditorTimeAdd()
{
if (Application.isPlaying) return;
if (m_elementPrefab == null)
{
Debug.LogError("listview is missing element prefab");
return;
}
TempListElementUI spawnObject = (TempListElementUI)UnityEditor.PrefabUtility.InstantiatePrefab(m_elementPrefab, Container);
m_actualUsedComponents.Add(spawnObject);
}

[ContextMenu("editor time clear")]
private void EditorTimeClear()
{
if (Application.isPlaying) return;
// remove pre objects
TempListElementUI[] preObjects = Container.GetComponentsInChildren<TempListElementUI>();
for (int i = 0; i < preObjects.Length; i++)
{
GameObject.DestroyImmediate(preObjects[i].ElementRectTransform.gameObject);
}
m_actualUsedComponents.Clear();
}

[ContextMenu("test print elements")]
private void TestPrintListElements()
{
if (m_actualUsedComponents != null)
{
StringBuilder printText = new StringBuilder($"temp list view children_{m_actualUsedComponents.Count} :\n");
for (int i = 0; i < m_actualUsedComponents.Count; i++)
{
printText.AppendLine(m_actualUsedComponents[i].ElementRectTransform.name);
}
Debug.Log(printText);
}
}

#endif
}

public class BoundlessScrollRectController : UIBehaviour
{
/*
too much code, I will just show the draw
*/

private void DrawContentItem()
{
IReadOnlyList<TempListElementUI> elementList = ElementList;
int dataCount = m_simulatedDataCount;
// TODO @Hiko use a general calculation later
bool test = m_content.anchorMin != Vector2.up || m_content.anchorMax != Vector2.up || m_content.pivot != Vector2.up;
if (test)
{
m_content.anchorMin = Vector2.up;
m_content.anchorMax = Vector2.up;
m_content.pivot = Vector2.up;
}
Vector3 dragContentAnchorPostion = m_content.anchoredPosition;
Vector3 contentMove = dragContentAnchorPostion - SomeUtils.GetOffsetLocalPosition(m_content, SomeUtils.UIOffsetType.TopLeft);
Vector2 itemSize = m_gridLayoutGroup.CellSize, spacing = m_gridLayoutGroup.Spacing;

RectOffset padding = null;
if (null != m_gridLayoutGroup)
padding = m_gridLayoutGroup.RectPadding;

// need to know the moving direction, then adjust it to prevent wrong draw
float xMove = contentMove.x < 0 ? (-contentMove.x - padding.horizontal) : 0;
xMove = Mathf.Clamp(xMove, 0.0f, Mathf.Abs(xMove));
float yMove = contentMove.y > 0 ? (contentMove.y - padding.vertical) : 0;
yMove = Mathf.Clamp(yMove, 0.0f, Mathf.Abs(yMove));

// the column index of the top left item
int tempColumnIndex = Mathf.FloorToInt((xMove + spacing.x) / (itemSize.x + spacing.x));
if (xMove % (itemSize.x + spacing.x) - itemSize.x > spacing.x)
tempColumnIndex = Mathf.Clamp(tempColumnIndex - 1, 0, tempColumnIndex);

// the row index of the top left item
int tempRowIndex = Mathf.FloorToInt((yMove + spacing.y) / (itemSize.y + spacing.y));
if (yMove % (itemSize.y + spacing.y) - itemSize.y > spacing.y)
tempRowIndex = Mathf.Clamp(tempRowIndex - 1, 0, tempRowIndex);

Vector2Int rowTopLeftItemIndex = new Vector2Int(tempRowIndex, tempColumnIndex);

int rowDataCount = 0, columnDataCount = 0;
if (BoundlessGridLayoutData.Constraint.FixedColumnCount == m_gridLayoutGroup.constraint)
{
rowDataCount = m_gridLayoutGroup.constraintCount;
columnDataCount = Mathf.CeilToInt((float)dataCount / rowDataCount);
}
else
{
columnDataCount = m_gridLayoutGroup.constraintCount;
rowDataCount = Mathf.CeilToInt((float)dataCount / columnDataCount);
}

// x -> element amount on horizontal axis
// y -> element amount on vertical axis
Vector2Int contentRowColumnSize = new Vector2Int(rowDataCount, columnDataCount);

// deal with content from left to right (simple case)
int dataIndex = 0, uiItemIndex = 0;
Vector3 rowTopLeftPosition = new Vector3(padding.left, -padding.top, 0.0f), itemTopLeftPosition = Vector3.zero;
for (int columnIndex = 0; columnIndex < m_viewItemCountInColumn; columnIndex++)
{
if (columnIndex + rowTopLeftItemIndex.x == columnDataCount)
break;

rowTopLeftPosition = new Vector3(padding.left, -padding.top, 0.0f) + Vector3.down * (columnIndex + rowTopLeftItemIndex.x) * (itemSize.y + spacing.y);
for (int rowIndex = 0; rowIndex < m_viewItemCountInRow; rowIndex++)
{
if (rowIndex + rowTopLeftItemIndex.y == rowDataCount)
break;

Vector2Int elementIndex = new Vector2Int(rowIndex + rowTopLeftItemIndex.y, columnIndex + rowTopLeftItemIndex.x);
dataIndex = CaculateDataIndex(elementIndex, contentRowColumnSize, GridLayoutData.startAxis, GridLayoutData.startCorner);
itemTopLeftPosition = rowTopLeftPosition + Vector3.right * (rowIndex + rowTopLeftItemIndex.y) * (itemSize.x + spacing.x);

// TODO @Hiko avoid overdraw
if (uiItemIndex > 0 && elementList[uiItemIndex - 1].ElementIndex == dataIndex)
continue; // over draw case
if (dataIndex > -1 && dataIndex < dataCount)
{
elementList[uiItemIndex].ElementRectTransform.localPosition = itemTopLeftPosition;
elementList[uiItemIndex].SetIndex(dataIndex);
elementList[uiItemIndex].Show();
uiItemIndex++;
}
}
}

while (uiItemIndex < elementList.Count)
{
elementList[uiItemIndex].SetIndex(-1);
elementList[uiItemIndex].Hide();
elementList[uiItemIndex].ElementRectTransform.position = Vector3.zero;
uiItemIndex++;
}

NotifyOnContentItemFinishDrawing();
}
}

这样写可以保证我的这个循环滚动的逻辑与实际的UI需要显示的逻辑尽可能地分开。需要使用循环滚动功能的时候,再添加这些组件即可。

配合实际显示组件使用

在滚动视图摆放好位置后,我们可以通过 ListElement 提供的索引知道其对应数据集里数据索引。所以需要使用这个滚动视图的UI,要同时管理 ListView 和 ScrollrectController。

我整了一个接口和一个扩展方法,用于显示数据的UI组件需要继承这个接口。因为按照我的写法,从 ListView 中只能拿到 ListElement 没法拿到实际用于表现的 UI 组件。但是通过这个接口和 ListElement 中的一个泛型方法就可以把内容传进去。虽然需要做一次类型检查,但也应该还好。毕竟不需要GetComponent也不需要单独维护一个关于实际显示组件的数组,用起来还是挺方便的呀。

除此之外比较坑的就是这样写导致 Unity 的 Component 都能调用这个Setup的方法,还挺危险的,所以加了个注释。实际使用的时候一定记得让用于表现的 UI 组件继承这个接口。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

// apply this on the target shows actual data
public interface ISetupable<TData>
{
void Setup(TData data);
}

/// <summary>
/// target component should inherit from ISetupable
/// </summary>
/// <param name="component"></param>
/// <param name="data"></param>
/// <typeparam name="TData"></typeparam>
public static void ISetup<TComponent, TData>(this Component component, TData data) where TComponent : Component
{
// I guess this check should be fine :(
if (component is ISetupable<TData> target)
{
target.Setup(data);
return;
}
UnityEngine.Debug.LogError($"component_{component.name} is not a setupable");
}

总结

其实这个功能丢尽项目里后用的地方不是特别多,所以都是隔几个月修一下,每次都要看自己写的垃圾代码好久,属实头痛了。
总算是用起来比以前方便不少了。接下来就把一些小问题修一修好了。

回头再看已经和自己做的那个初版完全不一样了,个人认为思路比以前那个好多了。

全部代码:
https://github.com/2C2C2C/MuyScrollRect